constable-rails 0.1.0 → 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.
@@ -19,6 +19,7 @@ module Constable
19
19
  "coverage_threshold" => 90,
20
20
  "coverage_html" => false,
21
21
  "fail_on_warnings" => false,
22
+ "output" => "concise",
22
23
  "parallel_workers" => "auto",
23
24
  "tiers" => {
24
25
  "unit" => "test/cases/models/**/*",
@@ -33,8 +34,27 @@ module Constable
33
34
 
34
35
  def self.load(root: Constable.root, overrides: {})
35
36
  path = File.join(root.to_s, CONFIG_PATH)
36
- raw = File.exist?(path) ? (YAML.safe_load_file(path, permitted_classes: [], aliases: true) || {}) : {}
37
- new(raw, root: root, overrides: overrides)
37
+ new(read_file(path), root: root, overrides: overrides)
38
+ end
39
+
40
+ # A typo in config.yml used to surface as a raw Psych::SyntaxError, or -- for a file
41
+ # that parsed but wasn't a mapping -- as "no implicit conversion of Array into Hash"
42
+ # from somewhere deep in the merge. Neither says which file to open.
43
+ def self.read_file(path)
44
+ return {} unless File.exist?(path)
45
+
46
+ loaded = YAML.safe_load_file(path, permitted_classes: [], aliases: true)
47
+ return {} if loaded.nil?
48
+
49
+ unless loaded.is_a?(Hash)
50
+ raise Constable::Error,
51
+ "#{CONFIG_PATH} must be a mapping of settings, but it parsed as " \
52
+ "#{loaded.class.name.downcase}. Check the indentation."
53
+ end
54
+
55
+ loaded
56
+ rescue Psych::SyntaxError => e
57
+ raise Constable::Error, "#{CONFIG_PATH} is not valid YAML: #{e.problem} at line #{e.line}."
38
58
  end
39
59
 
40
60
  def initialize(raw = {}, root: Constable.root, overrides: {})
@@ -45,14 +65,42 @@ module Constable
45
65
 
46
66
  def cold_cases = Array(@raw["cold_cases"])
47
67
  def warrants? = truthy(@raw["warrants"])
48
- def warrant_retries = @raw["warrant_retries"].to_i
68
+ # Negative retries are a typo for "off", not an instruction to count backwards.
69
+ def warrant_retries = [@raw["warrant_retries"].to_i, 0].max
49
70
  def auto_relink? = truthy(@raw["auto_relink"])
50
- def parole_period = @raw["parole_period"].to_i
71
+
72
+ # Clamped here rather than at each call site: Jail already refused a period of zero
73
+ # ("release on sight" is not parole), but the reporter read the raw value and would
74
+ # cheerfully print "Day 1 of 0 -- 0 clean runs to go" while the docket waited for 10.
75
+ def parole_period
76
+ period = @raw["parole_period"].to_i
77
+ period.positive? ? period : DEFAULTS["parole_period"]
78
+ end
79
+
51
80
  def coverage? = truthy(@raw["coverage"])
52
- def coverage_threshold = @raw["coverage_threshold"].to_i
81
+ # Clamped: a threshold above 100 is a build that can never go green, and a negative
82
+ # one is a gate that can never fail. Both are typos rather than intentions.
83
+ def coverage_threshold = @raw["coverage_threshold"].to_i.clamp(0, 100)
53
84
  def coverage_html? = truthy(@raw["coverage_html"])
54
85
  def fail_on_warnings? = truthy(@raw["fail_on_warnings"])
55
86
  def tiers = @raw["tiers"] || {}
87
+
88
+ # How much the live stream says while the suite runs.
89
+ #
90
+ # concise one glyph per test, grouped into a run per case. The default: a
91
+ # 1,000-test suite stays inside one screen.
92
+ # expanded a line per test -- glyph, name, duration. Slower to read in bulk,
93
+ # but you can see which test is hanging without waiting for the summary.
94
+ #
95
+ # The summary itself is identical either way. This only affects the live stream.
96
+ OUTPUT_MODES = %i[concise expanded].freeze
97
+
98
+ def output_mode
99
+ mode = @raw["output"].to_s.strip.downcase.to_sym
100
+ OUTPUT_MODES.include?(mode) ? mode : :concise
101
+ end
102
+
103
+ def expanded_output? = output_mode == :expanded
56
104
  def storage = @raw["storage"] || {}
57
105
 
58
106
  def storage_adapter = (storage["adapter"] || "sqlite").to_s
@@ -33,6 +33,25 @@ module Constable
33
33
  digest("cold:#{relative}:#{description}")
34
34
  end
35
35
 
36
+ # Two investigations with byte-identical bodies hash to the same key, which would
37
+ # make them one test as far as the blotter is concerned: jail one and the other goes
38
+ # with it, and their flake histories merge into a single misleading record.
39
+ #
40
+ # Bodies repeat more often than the "content hash" idea suggests --
41
+ # `attest(build(:thing, name: nil)).not_to be_valid` is the same handful of tokens in
42
+ # every model case, and the model generator writes an identical first investigation
43
+ # into every file it touches. So the collision is routine, not theoretical.
44
+ #
45
+ # When it happens, the colliding tests are re-keyed on the body *plus* their class
46
+ # and description. The rename-survival promise is weaker for exactly those tests --
47
+ # rewording one of them starts its history over -- which is the right trade: a
48
+ # history that belongs to two tests at once is worse than one that resets.
49
+ def disambiguate(base, case_name:, description:, ordinal: nil)
50
+ key = "#{base}:#{case_name}:#{description}"
51
+ key = "#{key}:#{ordinal}" unless ordinal.nil?
52
+ digest(key)
53
+ end
54
+
36
55
  def digest(string)
37
56
  Digest::SHA256.hexdigest(string)[0, LENGTH]
38
57
  end
@@ -508,6 +508,8 @@ module Constable
508
508
  "stub object.")
509
509
  end
510
510
 
511
+ flag_unknown_matcher(node, args.first) if %i[to not_to to_not].include?(name)
512
+
511
513
  if MOCK_ENTRY_POINTS.include?(name) && receiver.nil?
512
514
  note_untouched(:rspec_mocks, node,
513
515
  "`#{first_line(node)}` uses rspec-mocks. Constable has no equivalent; convert it by hand.")
@@ -532,6 +534,14 @@ module Constable
532
534
  end
533
535
  when :should, :should_not
534
536
  flag(:should_syntax, node, "`#{name}` is RSpec's monkey-patched expectation syntax. Use `attest(...).to`.")
537
+ when :helper
538
+ if receiver.nil? && args.empty?
539
+ flag(:rspec_helper_object, node,
540
+ "`helper` is RSpec's helper-spec proxy and has no Constable equivalent. " \
541
+ "A helper is a plain module: `include YourHelper` in the case and call " \
542
+ "the method directly -- which is exactly what `rails generate helper` " \
543
+ "writes.")
544
+ end
535
545
  when :described_class
536
546
  if receiver.nil?
537
547
  flag(:described_class, node,
@@ -558,6 +568,36 @@ module Constable
558
568
  record_converted(:helper_require, node, value, replaced)
559
569
  end
560
570
 
571
+ # Constable's matcher set is deliberately smaller than RSpec's, and the rewrite
572
+ # carries any matcher name straight across. Without this check the first anyone
573
+ # hears about it is a NoMethodError at runtime, naming the matcher -- or worse, an
574
+ # internal deferred class -- rather than the line that needs a decision.
575
+ def flag_unknown_matcher(node, matcher_node)
576
+ name = root_matcher_name(matcher_node)
577
+ return if name.nil?
578
+ return if Constable::Matchers.matcher_name?(name)
579
+
580
+ flag(:unknown_matcher, node,
581
+ "`#{name}` is not one of Constable's matchers. Define it in " \
582
+ "test/support/matchers.rb with `Constable::Matchers.define(:#{name})`, or " \
583
+ "rewrite the assertion.")
584
+ end
585
+
586
+ # `contain_exactly(1, 2)` -> :contain_exactly. `be_within(0.5).of(10)` -> :be_within.
587
+ # `change { x }.by(1)` -> :change. Anything that is not ultimately a bare method
588
+ # call -- a local variable holding a matcher, a constant -- returns nil and is left
589
+ # alone, because we cannot know what it is.
590
+ def root_matcher_name(node)
591
+ return nil unless node.is_a?(::Parser::AST::Node)
592
+
593
+ current = node
594
+ current = current.children.first while current.type == :send && current.children.first
595
+
596
+ return nil unless current.type == :send && current.children.first.nil?
597
+
598
+ current.children[1]
599
+ end
600
+
561
601
  def mock_expectation?(node)
562
602
  return false unless node.is_a?(::Parser::AST::Node)
563
603
 
@@ -38,6 +38,16 @@ module Constable
38
38
  @identity ||= Identity.for_block(@block)
39
39
  end
40
40
 
41
+ # Re-keys this investigation because another one has the same body. Called by the
42
+ # registry once the whole suite is loaded, which is the first moment a collision can
43
+ # be seen. See Identity.disambiguate.
44
+ def disambiguate!(ordinal: nil)
45
+ @identity = Identity.disambiguate(identity, case_name: case_name,
46
+ description: full_description,
47
+ ordinal: ordinal)
48
+ self
49
+ end
50
+
41
51
  def location
42
52
  "#{relative_file}:#{@line}"
43
53
  end
@@ -267,13 +267,20 @@ module Constable
267
267
  # Call it *before* writing the result to flake history (the flip check reads the
268
268
  # previous status) and *after* Warrants has had its say (a warranted result is not a
269
269
  # failure, so it never reaches the docket).
270
- def adjudicate(result, jail_mode: false)
270
+ # `systemic:` says this run failed for a reason that has nothing to do with any
271
+ # individual test -- see Runner#systemic_failure. The failures still stand and the
272
+ # build still goes red, but they are not *evidence*: nothing moves through the state
273
+ # machine, because "the database was locked for the whole run" is not a fact about a
274
+ # test and must not put one on the docket.
275
+ def adjudicate(result, jail_mode: false, systemic: false)
271
276
  return result if result.nil?
272
277
 
273
278
  docket = entry(result.identity)
274
279
 
280
+ return mark_jailed(result) if docket&.jailed?
281
+ return result if systemic
282
+
275
283
  return record_result(result) if docket&.paroled?
276
- return mark_jailed(result) if docket&.jailed?
277
284
  return result unless result.failed?
278
285
 
279
286
  if jail_mode
@@ -331,17 +338,37 @@ module Constable
331
338
  # test that is not on the docket yet.
332
339
  #
333
340
  # Returns an identity String, or nil when nothing matches.
334
- def resolve(target)
341
+ # Every docket row a target could mean.
342
+ #
343
+ # The interesting case is a bare path. "test/cases/users_case.rb" with three tests
344
+ # on the docket is a question, not an instruction: picking one silently acts on a
345
+ # test the user never named -- and not even the first one, since the order is
346
+ # whatever storage returns. Callers ask for the candidates and refuse to guess.
347
+ def candidates(target)
335
348
  text = target.to_s.strip
336
- return nil if text.empty?
337
- return text if identity_like?(text) && entry(text)
349
+ return [] if text.empty?
350
+
351
+ if identity_like?(text) && (row = entry(text))
352
+ return [row]
353
+ end
338
354
 
339
355
  file, line = self.class.split_target(text)
340
- return nil if file.empty?
356
+ return [] if file.empty?
341
357
 
342
358
  matches = entries.select { |e| self.class.same_path?(e.file, file) }
343
359
  matches = matches.select { |e| e.line == line } if line
344
- return matches.first.identity if matches.any?
360
+ matches
361
+ end
362
+
363
+ # An identity String, or nil when nothing matches -- or when more than one does.
364
+ # Ambiguity is the caller's to report, with the candidates in hand.
365
+ def resolve(target)
366
+ matches = candidates(target)
367
+ return matches.first.identity if matches.size == 1
368
+ return nil unless matches.empty?
369
+
370
+ file, line = self.class.split_target(target.to_s.strip)
371
+ return nil if file.empty?
345
372
 
346
373
  self.class.registry_identity(file, line)
347
374
  end
@@ -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 HTTP_STATUS_CODES[expected.to_s.to_sym]
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
 
@@ -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