constable-rails 0.1.0 → 1.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 +4 -4
- data/CHANGELOG.md +277 -1
- data/README.md +174 -13
- data/lib/constable/case.rb +33 -0
- data/lib/constable/cli.rb +90 -5
- data/lib/constable/cold_case/rspec.rb +93 -1
- data/lib/constable/config.rb +95 -8
- data/lib/constable/identity.rb +19 -0
- data/lib/constable/importer/modernizer.rb +40 -0
- data/lib/constable/investigation.rb +10 -0
- data/lib/constable/jail.rb +72 -7
- data/lib/constable/matchers.rb +206 -2
- data/lib/constable/registry.rb +24 -0
- data/lib/constable/reporter.rb +232 -3
- data/lib/constable/runner.rb +153 -3
- data/lib/constable/selection.rb +23 -1
- data/lib/constable/storage/sqlite_adapter.rb +21 -1
- data/lib/constable/version.rb +1 -1
- data/lib/constable/warrants.rb +56 -5
- data/lib/constable/worker_databases.rb +83 -0
- data/lib/constable.rb +48 -1
- data/lib/generators/constable/install_generator.rb +140 -9
- data/lib/generators/constable/templates/case_helper.rb.tt +77 -23
- data/lib/generators/constable/templates/config.yml.tt +32 -7
- metadata +4 -3
data/lib/constable/matchers.rb
CHANGED
|
@@ -33,7 +33,7 @@ module Constable
|
|
|
33
33
|
not_found: 404, method_not_allowed: 405, not_acceptable: 406,
|
|
34
34
|
request_timeout: 408, conflict: 409, gone: 410, precondition_failed: 412,
|
|
35
35
|
payload_too_large: 413, unsupported_media_type: 415, im_a_teapot: 418,
|
|
36
|
-
unprocessable_entity: 422, locked: 423, too_many_requests: 429,
|
|
36
|
+
unprocessable_entity: 422, unprocessable_content: 422, locked: 423, too_many_requests: 429,
|
|
37
37
|
internal_server_error: 500, not_implemented: 501, bad_gateway: 502,
|
|
38
38
|
service_unavailable: 503, gateway_timeout: 504
|
|
39
39
|
}.freeze
|
|
@@ -44,6 +44,22 @@ module Constable
|
|
|
44
44
|
error: (500..599), server_error: (500..599)
|
|
45
45
|
}.freeze
|
|
46
46
|
|
|
47
|
+
# Rack renames statuses -- 422 became :unprocessable_content in Rack 3.1, and Rails
|
|
48
|
+
# 8.1 deprecates the old spelling -- so the table above is a floor, not the whole
|
|
49
|
+
# truth. Anything Rack knows is accepted, which means a rename costs no release here.
|
|
50
|
+
def self.rack_status_codes
|
|
51
|
+
return @rack_status_codes if defined?(@rack_status_codes)
|
|
52
|
+
|
|
53
|
+
@rack_status_codes =
|
|
54
|
+
if defined?(::Rack::Utils::SYMBOL_TO_STATUS_CODE)
|
|
55
|
+
::Rack::Utils::SYMBOL_TO_STATUS_CODE.transform_keys(&:to_sym)
|
|
56
|
+
else
|
|
57
|
+
{}
|
|
58
|
+
end
|
|
59
|
+
rescue StandardError
|
|
60
|
+
@rack_status_codes = {}
|
|
61
|
+
end
|
|
62
|
+
|
|
47
63
|
MAX_INSPECT = 200
|
|
48
64
|
MAX_BODY = 800
|
|
49
65
|
|
|
@@ -222,7 +238,9 @@ module Constable
|
|
|
222
238
|
case expected
|
|
223
239
|
when Integer then expected
|
|
224
240
|
when /\A\d+\z/ then expected.to_i
|
|
225
|
-
else
|
|
241
|
+
else
|
|
242
|
+
name = expected.to_s.to_sym
|
|
243
|
+
HTTP_STATUS_CODES[name] || Matchers.rack_status_codes[name]
|
|
226
244
|
end
|
|
227
245
|
end
|
|
228
246
|
|
|
@@ -345,6 +363,104 @@ module Constable
|
|
|
345
363
|
end
|
|
346
364
|
end
|
|
347
365
|
|
|
366
|
+
# `be_within(0.5).of(10)` -- a matcher spelled across two calls, so it has to survive
|
|
367
|
+
# the first one and collect its subject on the second.
|
|
368
|
+
#
|
|
369
|
+
# Registering it matters for a second reason: without an entry, `be_within` fell
|
|
370
|
+
# through to the be_*/have_* predicate fallback, which happily built a
|
|
371
|
+
# PredicateDeferred and then blew up on `.of` with a NoMethodError naming an internal
|
|
372
|
+
# class rather than the matcher the author actually wrote.
|
|
373
|
+
class WithinDeferred < Deferred
|
|
374
|
+
def of(expected)
|
|
375
|
+
@expected = expected
|
|
376
|
+
@expected_set = true
|
|
377
|
+
self
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
def matches?(actual)
|
|
381
|
+
unless @expected_set
|
|
382
|
+
return [false, "be_within(#{@args.first.inspect}) is incomplete -- it needs .of: " \
|
|
383
|
+
"attest(value).to be_within(0.5).of(10)", nil]
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
delta = @args.first
|
|
387
|
+
difference = (actual - @expected).abs
|
|
388
|
+
return true if difference <= delta
|
|
389
|
+
|
|
390
|
+
[false, "expected #{Matchers.describe(actual)} to be within #{delta.inspect} of " \
|
|
391
|
+
"#{Matchers.describe(@expected)}, but it differed by #{difference}", nil]
|
|
392
|
+
rescue NoMethodError, TypeError, ArgumentError
|
|
393
|
+
[false, "expected #{Matchers.describe(actual)} to be within #{delta.inspect} of " \
|
|
394
|
+
"#{Matchers.describe(@expected)}, but a #{actual.class} cannot be subtracted", nil]
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
def description
|
|
398
|
+
return "be within #{@args.first.inspect} of #{Matchers.describe(@expected)}" if @expected_set
|
|
399
|
+
|
|
400
|
+
"be within #{@args.first.inspect} of (nothing -- .of was never called)"
|
|
401
|
+
end
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
# `be`, in its three RSpec spellings:
|
|
405
|
+
#
|
|
406
|
+
# attest(x).to be(other) identity -- the same object, not merely equal
|
|
407
|
+
# attest(x).to be >= 0 an operator comparison
|
|
408
|
+
# attest(x).to be truthiness
|
|
409
|
+
#
|
|
410
|
+
# `==` is deliberately not among the operators. Defining it on a matcher object
|
|
411
|
+
# breaks equality everywhere the object is compared, and `eq` already says it.
|
|
412
|
+
class BeDeferred < Deferred
|
|
413
|
+
COMPARISONS = %i[< <= > >=].freeze
|
|
414
|
+
|
|
415
|
+
COMPARISONS.each do |operator|
|
|
416
|
+
define_method(operator) do |operand|
|
|
417
|
+
@operator = operator
|
|
418
|
+
@operand = operand
|
|
419
|
+
self
|
|
420
|
+
end
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
def matches?(actual)
|
|
424
|
+
return compare(actual) if @operator
|
|
425
|
+
# `.empty?`, not `.any?`: `[nil].any?` is false, which would send `be(nil)` down
|
|
426
|
+
# the truthiness branch and assert the opposite of what was written.
|
|
427
|
+
return identity(actual) unless @args.empty?
|
|
428
|
+
return true if actual
|
|
429
|
+
|
|
430
|
+
[false, "expected a truthy value, but got #{actual.inspect}", nil]
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
def description
|
|
434
|
+
return "be #{@operator} #{Matchers.describe(@operand)}" if @operator
|
|
435
|
+
return "be #{Matchers.describe(@args.first)}" unless @args.empty?
|
|
436
|
+
|
|
437
|
+
"be truthy"
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
private
|
|
441
|
+
|
|
442
|
+
def compare(actual)
|
|
443
|
+
return true if actual.public_send(@operator, @operand)
|
|
444
|
+
|
|
445
|
+
[false, "expected #{Matchers.describe(actual)} to be #{@operator} " \
|
|
446
|
+
"#{Matchers.describe(@operand)}", nil]
|
|
447
|
+
rescue NoMethodError, ArgumentError, TypeError
|
|
448
|
+
[false, "expected #{Matchers.describe(actual)} to be #{@operator} " \
|
|
449
|
+
"#{Matchers.describe(@operand)}, but a #{actual.class} cannot be compared", nil]
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
# `be` is identity, not equality -- that distinction is the only reason to reach for
|
|
453
|
+
# it over `eq`, so the failure message says which one failed.
|
|
454
|
+
def identity(actual)
|
|
455
|
+
expected = @args.first
|
|
456
|
+
return true if actual.equal?(expected)
|
|
457
|
+
|
|
458
|
+
hint = actual == expected ? " (they are equal, but not the same object)" : ""
|
|
459
|
+
[false, "expected #{Matchers.describe(actual)} to be the same object as " \
|
|
460
|
+
"#{Matchers.describe(expected)}#{hint}", nil]
|
|
461
|
+
end
|
|
462
|
+
end
|
|
463
|
+
|
|
348
464
|
# The be_*/have_* fallback: with no matcher registered under the name, the name itself
|
|
349
465
|
# is the assertion -- `be_created` asks the actual whether it is `created?`.
|
|
350
466
|
class PredicateDeferred < Deferred
|
|
@@ -795,6 +911,94 @@ module Constable
|
|
|
795
911
|
raise Constable::Error, "change is only usable through attest { ... }.to change { ... }"
|
|
796
912
|
end
|
|
797
913
|
|
|
914
|
+
# Order-independent collection equality. `modernize` converts
|
|
915
|
+
# `expect(x).to contain_exactly(a, b)` verbatim, so not having it turned every
|
|
916
|
+
# converted spec that used it into a NoMethodError.
|
|
917
|
+
define_builtin(:contain_exactly) do |actual, *expected|
|
|
918
|
+
unless actual.respond_to?(:to_a)
|
|
919
|
+
next [false, "expected #{Matchers.describe(actual)} to be a collection, " \
|
|
920
|
+
"but a #{actual.class} does not respond to #to_a", nil]
|
|
921
|
+
end
|
|
922
|
+
|
|
923
|
+
items = actual.to_a
|
|
924
|
+
missing = expected.dup
|
|
925
|
+
extra = []
|
|
926
|
+
items.each do |item|
|
|
927
|
+
index = missing.index { |candidate| candidate == item }
|
|
928
|
+
index ? missing.delete_at(index) : extra << item
|
|
929
|
+
end
|
|
930
|
+
next true if missing.empty? && extra.empty?
|
|
931
|
+
|
|
932
|
+
parts = []
|
|
933
|
+
parts << "missing #{Matchers.describe(missing)}" unless missing.empty?
|
|
934
|
+
parts << "unexpected #{Matchers.describe(extra)}" unless extra.empty?
|
|
935
|
+
[false, "expected the collection to contain exactly #{expected.size} " \
|
|
936
|
+
"#{expected.size == 1 ? "item" : "items"}: #{parts.join(", ")}",
|
|
937
|
+
{ "Actual" => Matchers.describe(items) }]
|
|
938
|
+
end
|
|
939
|
+
# Not an alias: RSpec's match_array takes one array where contain_exactly takes
|
|
940
|
+
# varargs, so aliasing them makes match_array([1, 2]) assert that the collection
|
|
941
|
+
# holds a single element which is itself the array [1, 2].
|
|
942
|
+
define_builtin(:match_array) do |actual, expected|
|
|
943
|
+
unless expected.respond_to?(:to_a)
|
|
944
|
+
next [false, "match_array takes an array: attest(list).to match_array([1, 2])", nil]
|
|
945
|
+
end
|
|
946
|
+
|
|
947
|
+
Matchers.matcher_for(:contain_exactly).block.call(actual, *expected.to_a)
|
|
948
|
+
end
|
|
949
|
+
|
|
950
|
+
define_builtin(:start_with) do |actual, prefix|
|
|
951
|
+
unless actual.respond_to?(:start_with?) || actual.respond_to?(:first)
|
|
952
|
+
next [false, "expected #{Matchers.describe(actual)} to start with " \
|
|
953
|
+
"#{Matchers.describe(prefix)}, but a #{actual.class} cannot say", nil]
|
|
954
|
+
end
|
|
955
|
+
passed = if actual.respond_to?(:start_with?)
|
|
956
|
+
actual.start_with?(prefix)
|
|
957
|
+
else
|
|
958
|
+
actual.first(Array(prefix).size) == Array(prefix)
|
|
959
|
+
end
|
|
960
|
+
next true if passed
|
|
961
|
+
|
|
962
|
+
[false, "expected #{Matchers.describe(actual)} to start with #{Matchers.describe(prefix)}", nil]
|
|
963
|
+
end
|
|
964
|
+
|
|
965
|
+
define_builtin(:end_with) do |actual, suffix|
|
|
966
|
+
unless actual.respond_to?(:end_with?) || actual.respond_to?(:last)
|
|
967
|
+
next [false, "expected #{Matchers.describe(actual)} to end with " \
|
|
968
|
+
"#{Matchers.describe(suffix)}, but a #{actual.class} cannot say", nil]
|
|
969
|
+
end
|
|
970
|
+
passed = if actual.respond_to?(:end_with?)
|
|
971
|
+
actual.end_with?(suffix)
|
|
972
|
+
else
|
|
973
|
+
actual.last(Array(suffix).size) == Array(suffix)
|
|
974
|
+
end
|
|
975
|
+
next true if passed
|
|
976
|
+
|
|
977
|
+
[false, "expected #{Matchers.describe(actual)} to end with #{Matchers.describe(suffix)}", nil]
|
|
978
|
+
end
|
|
979
|
+
|
|
980
|
+
define_builtin(:be_between) do |actual, low, high|
|
|
981
|
+
next true if actual.between?(low, high)
|
|
982
|
+
|
|
983
|
+
[false, "expected #{Matchers.describe(actual)} to be between " \
|
|
984
|
+
"#{Matchers.describe(low)} and #{Matchers.describe(high)}", nil]
|
|
985
|
+
end
|
|
986
|
+
|
|
987
|
+
define_builtin(:satisfy) do |actual, &block|
|
|
988
|
+
next [false, "satisfy needs a block: attest(x).to satisfy { |value| ... }", nil] unless block
|
|
989
|
+
next true if block.call(actual)
|
|
990
|
+
|
|
991
|
+
[false, "expected #{Matchers.describe(actual)} to satisfy the block", nil]
|
|
992
|
+
end
|
|
993
|
+
|
|
994
|
+
define_builtin(:be_within, deferred_class: WithinDeferred) do |_actual, *_args|
|
|
995
|
+
raise Constable::Error, "be_within needs .of: attest(value).to be_within(0.5).of(10)"
|
|
996
|
+
end
|
|
997
|
+
|
|
998
|
+
define_builtin(:be, deferred_class: BeDeferred) do |_actual, *_args|
|
|
999
|
+
raise Constable::Error, "be is handled by BeDeferred and should never invoke its block"
|
|
1000
|
+
end
|
|
1001
|
+
|
|
798
1002
|
define_builtin(:be_a) do |actual, klass|
|
|
799
1003
|
next true if actual.is_a?(klass)
|
|
800
1004
|
|
data/lib/constable/registry.rb
CHANGED
|
@@ -42,6 +42,30 @@ module Constable
|
|
|
42
42
|
@cases.find { |klass| klass.constable_display_name == name.to_s || klass.name == name.to_s }
|
|
43
43
|
end
|
|
44
44
|
|
|
45
|
+
# Re-keys any investigations that share a body with another, so the blotter never
|
|
46
|
+
# treats two tests as one. Runs once, after the whole suite is loaded -- a collision
|
|
47
|
+
# is invisible until every case file has been seen.
|
|
48
|
+
#
|
|
49
|
+
# Returns the groups it re-keyed, so a caller can report them if it wants to.
|
|
50
|
+
def disambiguate_identities!
|
|
51
|
+
collisions = investigations.group_by(&:identity).select { |_key, group| group.size > 1 }
|
|
52
|
+
return [] if collisions.empty?
|
|
53
|
+
|
|
54
|
+
collisions.each_value { |group| group.each(&:disambiguate!) }
|
|
55
|
+
|
|
56
|
+
# Class and description are usually enough to tell two identical bodies apart. When
|
|
57
|
+
# they are not -- a copy-pasted `investigate` with the same name and the same body
|
|
58
|
+
# in the same case -- fall back to position, which is the only thing left that
|
|
59
|
+
# differs. History for those resets whenever the file is reordered, which is the
|
|
60
|
+
# honest cost of two tests that are indistinguishable by anything a human wrote.
|
|
61
|
+
still_colliding = investigations.group_by(&:identity).select { |_key, group| group.size > 1 }
|
|
62
|
+
still_colliding.each_value do |group|
|
|
63
|
+
group.each_with_index { |investigation, index| investigation.disambiguate!(ordinal: index) }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
collisions.values
|
|
67
|
+
end
|
|
68
|
+
|
|
45
69
|
def each(&) = @cases.each(&)
|
|
46
70
|
|
|
47
71
|
def size = @cases.size
|
data/lib/constable/reporter.rb
CHANGED
|
@@ -46,6 +46,11 @@ module Constable
|
|
|
46
46
|
|
|
47
47
|
DEFAULT_SLOWEST = 5
|
|
48
48
|
|
|
49
|
+
# Column the expanded stream right-aligns durations into. Descriptions are never
|
|
50
|
+
# truncated to fit it -- a clipped test name is not something you can grep for --
|
|
51
|
+
# so a long one simply pushes its own stamp out past the column.
|
|
52
|
+
EXPANDED_STAMP_COLUMN = 56
|
|
53
|
+
|
|
49
54
|
# Result::GLYPHS covers statuses. These four are summary vocabulary, not statuses:
|
|
50
55
|
# supervision and coverage are properties of a test, not outcomes of one.
|
|
51
56
|
GLYPHS = Result::GLYPHS.merge(
|
|
@@ -97,11 +102,14 @@ module Constable
|
|
|
97
102
|
|
|
98
103
|
attr_reader :io, :config, :seed, :total
|
|
99
104
|
|
|
100
|
-
def initialize(io: $stdout, config: nil, color: nil, slowest: DEFAULT_SLOWEST)
|
|
105
|
+
def initialize(io: $stdout, config: nil, color: nil, slowest: DEFAULT_SLOWEST, mode: nil)
|
|
101
106
|
@io = io
|
|
102
107
|
@config = config || Constable.config
|
|
103
108
|
@color = resolve_color(color)
|
|
104
109
|
@slowest = slowest.to_i
|
|
110
|
+
# Resolved lazily: the reporter is built before case_helper.rb has run, so reading
|
|
111
|
+
# the config now would miss anything Constable.configure sets.
|
|
112
|
+
@requested_mode = mode
|
|
105
113
|
@io.set_encoding(Encoding::UTF_8) if @io.respond_to?(:set_encoding)
|
|
106
114
|
|
|
107
115
|
reset_stream!
|
|
@@ -141,6 +149,14 @@ module Constable
|
|
|
141
149
|
def flush!
|
|
142
150
|
return self unless streaming?
|
|
143
151
|
|
|
152
|
+
# The expanded stream never buffers -- every line was written as it happened, so
|
|
153
|
+
# there is nothing left to emit, only a blank line before the summary.
|
|
154
|
+
if expanded?
|
|
155
|
+
reset_stream!
|
|
156
|
+
writeln
|
|
157
|
+
return self
|
|
158
|
+
end
|
|
159
|
+
|
|
144
160
|
close_stream_line
|
|
145
161
|
pending_case_names.each { |name| open_stream_line(name) && close_stream_line }
|
|
146
162
|
writeln
|
|
@@ -169,6 +185,9 @@ module Constable
|
|
|
169
185
|
|
|
170
186
|
section_parole_violations(results)
|
|
171
187
|
section_failures(results)
|
|
188
|
+
section_warrants(results)
|
|
189
|
+
section_jailed(results)
|
|
190
|
+
section_parole(results)
|
|
172
191
|
section_warnings(warnings)
|
|
173
192
|
section_slowest(results)
|
|
174
193
|
rename_suggestions(suggestions)
|
|
@@ -193,6 +212,8 @@ module Constable
|
|
|
193
212
|
def failed? = !success?
|
|
194
213
|
def color? = @color
|
|
195
214
|
def finished? = @finished
|
|
215
|
+
def mode = @mode ||= resolve_mode(@requested_mode)
|
|
216
|
+
def expanded? = mode == :expanded
|
|
196
217
|
|
|
197
218
|
private
|
|
198
219
|
|
|
@@ -211,6 +232,8 @@ module Constable
|
|
|
211
232
|
end
|
|
212
233
|
|
|
213
234
|
def stream(result)
|
|
235
|
+
return stream_expanded(result) if expanded?
|
|
236
|
+
|
|
214
237
|
name = result.case_name.to_s
|
|
215
238
|
name = "(anonymous)" if name.empty?
|
|
216
239
|
glyph = paint(result.glyph, COLORS[result.status])
|
|
@@ -263,6 +286,77 @@ module Constable
|
|
|
263
286
|
# Glyphs may carry escape codes; count only the visible ones.
|
|
264
287
|
def count_glyphs(string) = strip_ansi(string).length
|
|
265
288
|
|
|
289
|
+
# --- expanded stream -----------------------------------------------------------
|
|
290
|
+
#
|
|
291
|
+
# One line per test instead of one glyph. The trade is deliberate: concise keeps a
|
|
292
|
+
# thousand-test suite on one screen, expanded tells you which test is hanging while
|
|
293
|
+
# it hangs, without waiting for the summary.
|
|
294
|
+
#
|
|
295
|
+
# Workers interleave, so a case can come back after another has spoken. It gets a
|
|
296
|
+
# second header rather than having its later tests silently appended under the
|
|
297
|
+
# wrong one -- the same honesty rule the concise stream follows.
|
|
298
|
+
def stream_expanded(result)
|
|
299
|
+
name = result.case_name.to_s
|
|
300
|
+
name = "(anonymous)" if name.empty?
|
|
301
|
+
|
|
302
|
+
if @stream_case != name
|
|
303
|
+
writeln if @stream_case
|
|
304
|
+
writeln(INDENT + paint(name, :bold))
|
|
305
|
+
@stream_case = name
|
|
306
|
+
@stream_open = true
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
writeln(expanded_line(result))
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def expanded_line(result)
|
|
313
|
+
glyph = paint(result.glyph, COLORS[result.status])
|
|
314
|
+
line = "#{ENTRY_INDENT}#{glyph} #{expanded_description(result)}"
|
|
315
|
+
|
|
316
|
+
stamp = expanded_duration(result)
|
|
317
|
+
return line if stamp.nil?
|
|
318
|
+
|
|
319
|
+
# Pad to a column so the durations line up, but never truncate a description --
|
|
320
|
+
# a clipped test name is not something you can grep for.
|
|
321
|
+
visible = strip_ansi(line).length
|
|
322
|
+
gap = [EXPANDED_STAMP_COLUMN - visible, 1].max
|
|
323
|
+
"#{line}#{" " * gap}#{paint(stamp, :dim)}"
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def expanded_description(result)
|
|
327
|
+
description = result.description.to_s
|
|
328
|
+
description = "(no description)" if description.empty?
|
|
329
|
+
# A jailed test never ran its body, so say why rather than implying it passed.
|
|
330
|
+
return "#{description} #{paint("— #{result.jail_reason}", :dim)}" if jail_reason_worth_showing?(result)
|
|
331
|
+
|
|
332
|
+
description
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
def jail_reason_worth_showing?(result)
|
|
336
|
+
result.status == :jailed && !result.jail_reason.to_s.strip.empty?
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
# Only real, measured time. A jailed test never ran, and "0ms" would be a claim
|
|
340
|
+
# about a body that was skipped.
|
|
341
|
+
def expanded_duration(result)
|
|
342
|
+
return nil if result.status == :jailed
|
|
343
|
+
return nil unless result.duration.to_f.positive?
|
|
344
|
+
|
|
345
|
+
format_test_duration(result.duration)
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
# The summary's durations are run-scale, where "12.4s" is the useful unit. One test
|
|
349
|
+
# is usually sub-second, and "0.0s" against every line says nothing at all -- so the
|
|
350
|
+
# expanded stream counts milliseconds until a test is slow enough for seconds to mean
|
|
351
|
+
# something.
|
|
352
|
+
def format_test_duration(seconds)
|
|
353
|
+
seconds = seconds.to_f
|
|
354
|
+
return format_duration(seconds) if seconds >= 1
|
|
355
|
+
|
|
356
|
+
milliseconds = (seconds * 1000).round
|
|
357
|
+
milliseconds.zero? ? "<1ms" : "#{milliseconds}ms"
|
|
358
|
+
end
|
|
359
|
+
|
|
266
360
|
# --- header and headline -------------------------------------------------------
|
|
267
361
|
|
|
268
362
|
def header_line(results, duration)
|
|
@@ -359,8 +453,12 @@ module Constable
|
|
|
359
453
|
each_entry(violations) do |result|
|
|
360
454
|
writeln(INDENT + paint("#{GLYPHS[:parole_violation]} #{result.case_name}", COLORS[:parole_violation]))
|
|
361
455
|
writeln(ENTRY_INDENT + paint(%("#{result.description}"), :dim))
|
|
456
|
+
writeln(ENTRY_INDENT + paint(result.location, :dim))
|
|
362
457
|
writeln(ENTRY_INDENT + parole_violation_sentence(result))
|
|
363
458
|
end
|
|
459
|
+
hint("Somebody trusted this test again and it let them down, so it is back on the " \
|
|
460
|
+
"docket. Fix it before the next constable jail parole — a second violation " \
|
|
461
|
+
"is the signal that the test, not the flake, is the problem.")
|
|
364
462
|
end
|
|
365
463
|
|
|
366
464
|
def parole_violation_sentence(result)
|
|
@@ -369,6 +467,95 @@ module Constable
|
|
|
369
467
|
sentence
|
|
370
468
|
end
|
|
371
469
|
|
|
470
|
+
# A section says what happened; a hint says what to do about it. One dim sentence,
|
|
471
|
+
# and only where there is a real next step -- a tip printed on every run stops being
|
|
472
|
+
# read on the second one.
|
|
473
|
+
def hint(text)
|
|
474
|
+
writeln
|
|
475
|
+
lines = wrap(text, width: RULE_WIDTH - INDENT.length - 2, indent: " ")
|
|
476
|
+
writeln(INDENT + paint("→ #{lines.first}", :dim))
|
|
477
|
+
lines.drop(1).each { |line| writeln(INDENT + paint(line, :dim)) }
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
# Every test currently under a warrant: it failed, then passed when rerun in
|
|
481
|
+
# isolation, so it is flaky rather than broken. Loud, but not build-blocking.
|
|
482
|
+
def section_warrants(results)
|
|
483
|
+
warranted = results.select(&:warranted?)
|
|
484
|
+
return if warranted.empty?
|
|
485
|
+
|
|
486
|
+
section("WARRANTS")
|
|
487
|
+
each_entry(warranted) do |result|
|
|
488
|
+
writeln(INDENT + paint("#{GLYPHS[:warrant]} #{result.case_name}", COLORS[:warranted]))
|
|
489
|
+
writeln(ENTRY_INDENT + paint(%("#{result.description}"), :dim))
|
|
490
|
+
writeln(ENTRY_INDENT + paint(result.location, :dim))
|
|
491
|
+
writeln(ENTRY_INDENT + warrant_sentence(result))
|
|
492
|
+
end
|
|
493
|
+
hint("A warrant is \"not reproducible\", not \"not a problem\" — it stops blocking the " \
|
|
494
|
+
"build and stays visible until someone deals with it. " \
|
|
495
|
+
"Fixed the flake? constable warrants release PATH:LINE")
|
|
496
|
+
end
|
|
497
|
+
|
|
498
|
+
def warrant_sentence(result)
|
|
499
|
+
statuses = Array(result.retries).map(&:to_sym)
|
|
500
|
+
return "Failed once, then passed on retry." if statuses.empty?
|
|
501
|
+
|
|
502
|
+
passed = statuses.count(:passed)
|
|
503
|
+
"Failed, then passed #{passed} of #{statuses.size} #{pluralize(statuses.size, "retry")} " \
|
|
504
|
+
"run in isolation."
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
# The docket. These never ran their bodies, so they are neither passing nor failing --
|
|
508
|
+
# which is exactly why they get their own category rather than being folded into
|
|
509
|
+
# either one.
|
|
510
|
+
def section_jailed(results)
|
|
511
|
+
jailed = results.select { |result| result.status == :jailed }
|
|
512
|
+
return if jailed.empty?
|
|
513
|
+
|
|
514
|
+
section("JAILED")
|
|
515
|
+
each_entry(jailed) do |result|
|
|
516
|
+
writeln(INDENT + paint("#{GLYPHS[:jailed]} #{result.case_name}", COLORS[:jailed]))
|
|
517
|
+
writeln(ENTRY_INDENT + paint(%("#{result.description}"), :dim))
|
|
518
|
+
writeln(ENTRY_INDENT + paint(result.location, :dim))
|
|
519
|
+
writeln(ENTRY_INDENT + jailed_sentence(result))
|
|
520
|
+
end
|
|
521
|
+
hint("Jailed means skipped and tracked, not passing. Think one is fixed? " \
|
|
522
|
+
"constable jail parole PATH:LINE runs it for real again — " \
|
|
523
|
+
"#{@config.parole_period} clean runs and it releases itself.")
|
|
524
|
+
end
|
|
525
|
+
|
|
526
|
+
def jailed_sentence(result)
|
|
527
|
+
reason = result.jail_reason.to_s.strip
|
|
528
|
+
sentence = reason.empty? ? "Body skipped; setup still ran." : "#{reason.capitalize}."
|
|
529
|
+
sentence += " Its #{ordinalize(result.times_jailed)} time in jail." if result.times_jailed.to_i > 1
|
|
530
|
+
sentence
|
|
531
|
+
end
|
|
532
|
+
|
|
533
|
+
# Out on parole and behaving. Worth naming every run, because the count only means
|
|
534
|
+
# something if you can see it moving.
|
|
535
|
+
def section_parole(results)
|
|
536
|
+
paroled = results.select { |result| result.parole_day && !result.parole_violation? }
|
|
537
|
+
return if paroled.empty?
|
|
538
|
+
|
|
539
|
+
section("ON PAROLE")
|
|
540
|
+
each_entry(paroled) do |result|
|
|
541
|
+
writeln(INDENT + paint("#{GLYPHS[:parole]} #{result.case_name}", COLORS[:parole]))
|
|
542
|
+
writeln(ENTRY_INDENT + paint(%("#{result.description}"), :dim))
|
|
543
|
+
writeln(ENTRY_INDENT + paint(result.location, :dim))
|
|
544
|
+
writeln(ENTRY_INDENT + parole_progress_sentence(result))
|
|
545
|
+
end
|
|
546
|
+
hint("A paroled test runs for real and is watched: one failure sends it straight " \
|
|
547
|
+
"back to jail. constable watchlist shows everything under supervision.")
|
|
548
|
+
end
|
|
549
|
+
|
|
550
|
+
def parole_progress_sentence(result)
|
|
551
|
+
day = result.parole_day.to_i
|
|
552
|
+
period = @config.parole_period
|
|
553
|
+
remaining = [period - day, 0].max
|
|
554
|
+
return "Day #{day} of #{period} — releases after this run." if remaining.zero?
|
|
555
|
+
|
|
556
|
+
"Day #{day} of #{period} — #{remaining} #{pluralize(remaining, "clean run")} to go."
|
|
557
|
+
end
|
|
558
|
+
|
|
372
559
|
def section_failures(results)
|
|
373
560
|
failures = results.select(&:failed?)
|
|
374
561
|
return if failures.empty?
|
|
@@ -432,11 +619,21 @@ module Constable
|
|
|
432
619
|
writeln(INDENT + paint("#{GLYPHS[:warning]} #{message}", COLORS[:warning]))
|
|
433
620
|
else
|
|
434
621
|
writeln(INDENT + paint("#{GLYPHS[:warning]} #{location}", COLORS[:warning]))
|
|
435
|
-
message.
|
|
622
|
+
warning_message_lines(message).each { |line| writeln(ENTRY_INDENT + line) }
|
|
436
623
|
end
|
|
437
624
|
end
|
|
438
625
|
end
|
|
439
626
|
|
|
627
|
+
# A warning carries the author's own words -- an unsafe block's reason, a cold case's
|
|
628
|
+
# count -- and those are easily ninety columns. Wrapped to the frame, but respecting
|
|
629
|
+
# any line breaks the message already chose.
|
|
630
|
+
def warning_message_lines(message)
|
|
631
|
+
message.to_s.lines.flat_map do |line|
|
|
632
|
+
text = line.chomp
|
|
633
|
+
text.empty? ? [""] : wrap(text, width: RULE_WIDTH - ENTRY_INDENT.length, indent: "")
|
|
634
|
+
end
|
|
635
|
+
end
|
|
636
|
+
|
|
440
637
|
def section_slowest(results)
|
|
441
638
|
# A jailed test never ran its body, so it has no honest duration. A parole
|
|
442
639
|
# violation did run -- and failing slowly is still worth seeing.
|
|
@@ -551,7 +748,28 @@ module Constable
|
|
|
551
748
|
end
|
|
552
749
|
|
|
553
750
|
def pluralize(count, word)
|
|
554
|
-
count.to_i == 1
|
|
751
|
+
return word if count.to_i == 1
|
|
752
|
+
# "retry" -> "retries". Only the consonant-y rule earns a special case; every other
|
|
753
|
+
# word this reporter pluralizes takes a plain "s".
|
|
754
|
+
return "#{word[0..-2]}ies" if word.end_with?("y") && !"aeiou".include?(word[-2].to_s)
|
|
755
|
+
|
|
756
|
+
"#{word}s"
|
|
757
|
+
end
|
|
758
|
+
|
|
759
|
+
# Hints are prose, and prose that runs past the frame reads as a mistake. Wrapped to
|
|
760
|
+
# the same 60 columns the rules use, with continuation lines aligned under the arrow.
|
|
761
|
+
def wrap(text, width:, indent:)
|
|
762
|
+
words = text.split
|
|
763
|
+
lines = [+""]
|
|
764
|
+
words.each do |word|
|
|
765
|
+
candidate = lines.last.empty? ? word : "#{lines.last} #{word}"
|
|
766
|
+
if candidate.length <= width || lines.last.empty?
|
|
767
|
+
lines[-1] = candidate
|
|
768
|
+
else
|
|
769
|
+
lines << +word
|
|
770
|
+
end
|
|
771
|
+
end
|
|
772
|
+
lines.each_with_index.map { |line, i| i.zero? ? line : indent + line }
|
|
555
773
|
end
|
|
556
774
|
|
|
557
775
|
def format_duration(seconds)
|
|
@@ -571,6 +789,17 @@ module Constable
|
|
|
571
789
|
|
|
572
790
|
# Colour is a nicety; correctness is not. NO_COLOR, a pipe, a dumb terminal or an
|
|
573
791
|
# explicit --no-color all fall back to plain text with identical layout.
|
|
792
|
+
# An explicit argument (the --expanded / --concise flags) beats the config file, which
|
|
793
|
+
# beats the default. An unrecognized value falls back rather than raising: a typo in
|
|
794
|
+
# config.yml should not stop a suite from running.
|
|
795
|
+
def resolve_mode(mode)
|
|
796
|
+
configured = @config.respond_to?(:output_mode) ? @config.output_mode : :concise
|
|
797
|
+
return configured if mode.nil?
|
|
798
|
+
|
|
799
|
+
mode = mode.to_s.strip.downcase.to_sym
|
|
800
|
+
Config::OUTPUT_MODES.include?(mode) ? mode : configured
|
|
801
|
+
end
|
|
802
|
+
|
|
574
803
|
def resolve_color(color)
|
|
575
804
|
return !!color unless color.nil?
|
|
576
805
|
return false if ENV["NO_COLOR"] && !ENV["NO_COLOR"].empty?
|