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,171 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Constable
|
|
4
|
+
# Isolation is non-negotiable in native code. Everything that makes one investigation
|
|
5
|
+
# unable to reach another lives here: the rolled-back transaction, the fresh instance,
|
|
6
|
+
# and the leak check that catches what the transaction can't (globals, ENV, class
|
|
7
|
+
# variables -- the state a database rollback never touches).
|
|
8
|
+
module Isolation
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
# Whether a database is actually there, and nothing else.
|
|
12
|
+
#
|
|
13
|
+
# An earlier version of this skipped the transaction for the :unit tier, on the theory
|
|
14
|
+
# that a unit test has no database to roll back. That was wrong, and quietly so: the
|
|
15
|
+
# default tier config routes test/cases/models/** to :unit, and Rails model tests are
|
|
16
|
+
# precisely the ones that write rows. Records survived into the next investigation and
|
|
17
|
+
# isolation -- the one guarantee this framework refuses to compromise -- was gone.
|
|
18
|
+
#
|
|
19
|
+
# Not booting the database is the tier's business, decided in case_helper.rb. If a
|
|
20
|
+
# connection exists by the time a test runs, that test gets rolled back. Both things
|
|
21
|
+
# can be true, and only one of them is a promise to the developer.
|
|
22
|
+
def transactional?(_tier = nil)
|
|
23
|
+
return false unless defined?(::ActiveRecord::Base)
|
|
24
|
+
|
|
25
|
+
# `connection_pool.connected?` is false until something has actually checked a
|
|
26
|
+
# connection out, which on the first test of a run is nothing -- so asking it
|
|
27
|
+
# straight leaves the first investigation unwrapped. Checking one out settles the
|
|
28
|
+
# question honestly: it succeeds when there is a database and raises when there
|
|
29
|
+
# isn't.
|
|
30
|
+
::ActiveRecord::Base.connection_pool.with_connection { true }
|
|
31
|
+
rescue StandardError
|
|
32
|
+
false
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Runs the block inside a transaction that is always rolled back, so nothing a test
|
|
36
|
+
# writes survives it. Falls through to a plain yield when there's no database.
|
|
37
|
+
def with_rollback(tier)
|
|
38
|
+
return yield unless transactional?(tier)
|
|
39
|
+
|
|
40
|
+
result = nil
|
|
41
|
+
::ActiveRecord::Base.transaction(requires_new: true) do
|
|
42
|
+
result = yield
|
|
43
|
+
raise ::ActiveRecord::Rollback
|
|
44
|
+
end
|
|
45
|
+
result
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# A cheap snapshot of the process-level state a rollback would never restore.
|
|
49
|
+
def snapshot
|
|
50
|
+
{
|
|
51
|
+
globals: global_snapshot,
|
|
52
|
+
env: ENV.to_h,
|
|
53
|
+
class_variables: class_variable_snapshot,
|
|
54
|
+
constants: Object.constants.size
|
|
55
|
+
}
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Compares two snapshots and describes what leaked, in the terms a developer can act
|
|
59
|
+
# on. Returns [] when the investigation left the process as it found it.
|
|
60
|
+
def diff(before, after)
|
|
61
|
+
leaks = []
|
|
62
|
+
|
|
63
|
+
added_globals = after[:globals].keys - before[:globals].keys
|
|
64
|
+
changed_globals = (after[:globals].keys & before[:globals].keys).reject do |key|
|
|
65
|
+
after[:globals][key] == before[:globals][key]
|
|
66
|
+
end
|
|
67
|
+
leaks << "set global #{added_globals.join(", ")}" if added_globals.any?
|
|
68
|
+
leaks << "mutated global #{changed_globals.join(", ")}" if changed_globals.any?
|
|
69
|
+
|
|
70
|
+
added_env = after[:env].keys - before[:env].keys
|
|
71
|
+
changed_env = (after[:env].keys & before[:env].keys).reject { |k| after[:env][k] == before[:env][k] }
|
|
72
|
+
leaks << "set ENV #{added_env.join(", ")}" if added_env.any?
|
|
73
|
+
leaks << "mutated ENV #{changed_env.join(", ")}" if changed_env.any?
|
|
74
|
+
|
|
75
|
+
added_cvars = after[:class_variables] - before[:class_variables]
|
|
76
|
+
leaks << "set class variable #{added_cvars.join(", ")}" if added_cvars.any?
|
|
77
|
+
|
|
78
|
+
leaks
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# $stdout and friends move around legitimately during a run (the reporter captures
|
|
82
|
+
# them), and read-only specials are noise rather than signal. A leak check that cries
|
|
83
|
+
# wolf is worse than no leak check at all: developers learn to scroll past warnings,
|
|
84
|
+
# and then miss the real one.
|
|
85
|
+
# Written as strings rather than %i[] on purpose: a literal "$\\" inside %i[] escapes
|
|
86
|
+
# the following space and silently fuses two entries into one, which quietly drops
|
|
87
|
+
# both from the list.
|
|
88
|
+
IGNORED_GLOBALS = [
|
|
89
|
+
"$stdout", "$stderr", "$stdin", "$stdlog",
|
|
90
|
+
"$!", "$@", "$~", "$&", "$`", "$'", "$+",
|
|
91
|
+
"$1", "$2", "$3", "$4", "$5", "$6", "$7", "$8", "$9",
|
|
92
|
+
"$0", "$PROGRAM_NAME", "$LOAD_PATH", "$LOADED_FEATURES", "$\"", "$:", "$*",
|
|
93
|
+
"$$", "$?", "$,", "$;", "$/", "$\\", "$.", "$_", "$=",
|
|
94
|
+
"$DEBUG", "$VERBOSE", "$FILENAME"
|
|
95
|
+
].map(&:to_sym).freeze
|
|
96
|
+
|
|
97
|
+
# Every interpreter-flag global ($-I, $-w, $-0 ...) is an alias for something on the
|
|
98
|
+
# list above.
|
|
99
|
+
IGNORED_GLOBAL_PREFIX = "$-"
|
|
100
|
+
|
|
101
|
+
def global_snapshot
|
|
102
|
+
# Reading a global by name needs eval, and Ruby deprecates a few of them ($= among
|
|
103
|
+
# them) loudly enough to bury a test run in warnings that say nothing about the app.
|
|
104
|
+
original_verbose = $VERBOSE
|
|
105
|
+
$VERBOSE = nil
|
|
106
|
+
|
|
107
|
+
watched = global_variables - IGNORED_GLOBALS
|
|
108
|
+
watched.reject! { |name| name.to_s.start_with?(IGNORED_GLOBAL_PREFIX) }
|
|
109
|
+
|
|
110
|
+
watched.each_with_object({}) do |name, out|
|
|
111
|
+
value = begin
|
|
112
|
+
eval(name.to_s) # rubocop:disable Security/Eval -- the only way to read a global by name
|
|
113
|
+
rescue StandardError
|
|
114
|
+
:unreadable
|
|
115
|
+
end
|
|
116
|
+
out[name] = safe_identity(value)
|
|
117
|
+
end
|
|
118
|
+
ensure
|
|
119
|
+
$VERBOSE = original_verbose
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def class_variable_snapshot
|
|
123
|
+
watched_classes.flat_map do |klass|
|
|
124
|
+
klass.class_variables.map { |cvar| "#{klass}.#{cvar}" }
|
|
125
|
+
rescue StandardError
|
|
126
|
+
[]
|
|
127
|
+
end.sort
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# The set of classes worth watching, worked out once. Ruby's own stdlib lazily
|
|
131
|
+
# initialises class variables the first time you touch it -- Resolv::DNS::Message and
|
|
132
|
+
# URI::RFC3986_Parser both do -- and reporting those as application leaks would train
|
|
133
|
+
# everyone to ignore the section. Only classes defined by files inside the project,
|
|
134
|
+
# and outside its bundled gems, can leak state the developer is able to do anything
|
|
135
|
+
# about.
|
|
136
|
+
def watched_classes
|
|
137
|
+
@watched_classes ||= ObjectSpace.each_object(Class).select { |klass| app_defined?(klass) }
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def reset_watched_classes!
|
|
141
|
+
@watched_classes = nil
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def app_defined?(klass)
|
|
145
|
+
return false if klass.singleton_class?
|
|
146
|
+
|
|
147
|
+
name = klass.name
|
|
148
|
+
return false if name.nil?
|
|
149
|
+
return false if name.start_with?("Constable", "Minitest", "RSpec", "ActiveSupport", "ActiveRecord")
|
|
150
|
+
|
|
151
|
+
source = Object.const_source_location(name)&.first
|
|
152
|
+
return false if source.nil?
|
|
153
|
+
return false unless source.start_with?("#{Constable.root}/")
|
|
154
|
+
|
|
155
|
+
!source.include?("/vendor/") && !source.include?("/gems/")
|
|
156
|
+
rescue StandardError
|
|
157
|
+
false
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Compares by value where that's cheap and safe, by object identity otherwise, so the
|
|
161
|
+
# check never accidentally deep-freezes or serializes an application object.
|
|
162
|
+
def safe_identity(value)
|
|
163
|
+
case value
|
|
164
|
+
when nil, true, false, Numeric, Symbol then value
|
|
165
|
+
when String then value.dup
|
|
166
|
+
when Array, Hash then value.size
|
|
167
|
+
else value.object_id
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Constable
|
|
4
|
+
# The docket.
|
|
5
|
+
#
|
|
6
|
+
# Jail answers exactly one question: *does this test block the build?* (Whether the
|
|
7
|
+
# failure was even real is a different question, and warrants answer that one.)
|
|
8
|
+
#
|
|
9
|
+
# A test lands on the docket three ways, and all three land in the same place:
|
|
10
|
+
#
|
|
11
|
+
# 1. A flake-history flip -- it passed, then failed, with no code change. Identity is
|
|
12
|
+
# a content hash of the investigate body, so an unchanged hash that flips result
|
|
13
|
+
# *is* a flake by definition. Nothing else needs to be inferred.
|
|
14
|
+
# 2. A failure during a `--jail` run. The on-ramp for a big red legacy suite: run
|
|
15
|
+
# once in jail mode for a clean baseline, then work the docket down.
|
|
16
|
+
# 3. A parole violation -- a test that was trusted again and immediately let us down.
|
|
17
|
+
#
|
|
18
|
+
# Jailing is not hiding. It swaps "blocks the build" for "tracked and skipped", and a
|
|
19
|
+
# jailed test is always its own summary category -- never folded into passed. Only the
|
|
20
|
+
# `investigate` body is skipped; `briefing` and `witness` still run, so setup rot
|
|
21
|
+
# surfaces on the next ordinary run instead of ambushing whoever finally works the
|
|
22
|
+
# docket. That decision is exposed here as #skip_body?; the Runner does the skipping.
|
|
23
|
+
#
|
|
24
|
+
# The state machine is small: jailed <-> parole -> released.
|
|
25
|
+
#
|
|
26
|
+
# Storage owns the persistence and the counters (see Constable::Storage); this class
|
|
27
|
+
# owns the policy and hands the Runner and the reporter decided Results.
|
|
28
|
+
class Jail
|
|
29
|
+
# Why a test is on the docket. Stored as free text so the blotter stays readable to a
|
|
30
|
+
# human running `constable jail`, keyed by symbol so callers don't retype prose.
|
|
31
|
+
REASONS = {
|
|
32
|
+
flake: "flake history flip -- passed, then failed with no code change",
|
|
33
|
+
jail_mode: "failed during a --jail run",
|
|
34
|
+
parole_violation: "parole violation -- failed while out on parole",
|
|
35
|
+
manual: "jailed by hand"
|
|
36
|
+
}.freeze
|
|
37
|
+
|
|
38
|
+
# One row of the docket, normalized.
|
|
39
|
+
#
|
|
40
|
+
# Adapters hand back symbol-keyed hashes, but this wrapper is deliberately tolerant of
|
|
41
|
+
# string keys and of a couple of column aliases: the blotter is the kind of thing
|
|
42
|
+
# people point third-party adapters at, and a docket entry is not worth a NoMethodError
|
|
43
|
+
# over a spelling.
|
|
44
|
+
class Entry
|
|
45
|
+
attr_reader :identity, :label, :file, :line, :reason, :state, :times_jailed,
|
|
46
|
+
:parole_violations, :parole_day, :jailed_at, :paroled_at, :updated_at, :row
|
|
47
|
+
|
|
48
|
+
def self.wrap(row)
|
|
49
|
+
return nil if row.nil?
|
|
50
|
+
return row if row.is_a?(Entry)
|
|
51
|
+
return nil unless row.respond_to?(:to_h)
|
|
52
|
+
|
|
53
|
+
hash = row.to_h
|
|
54
|
+
hash.empty? ? nil : new(hash)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def initialize(row)
|
|
58
|
+
@row = row.each_with_object({}) { |(k, v), out| out[k.to_sym] = v }
|
|
59
|
+
@identity = @row[:identity].to_s
|
|
60
|
+
@label = @row[:label]
|
|
61
|
+
@file = @row[:file].to_s
|
|
62
|
+
@line = @row[:line]&.to_i
|
|
63
|
+
@reason = @row[:reason]
|
|
64
|
+
@state = normalize_state(@row[:state] || @row[:status])
|
|
65
|
+
@times_jailed = (@row[:times_jailed] || 1).to_i
|
|
66
|
+
@parole_violations = (@row[:parole_violations] || 0).to_i
|
|
67
|
+
@parole_day = (@row[:parole_clean_runs] || @row[:parole_day] || 0).to_i
|
|
68
|
+
@jailed_at = @row[:jailed_at]
|
|
69
|
+
@paroled_at = @row[:paroled_at]
|
|
70
|
+
@updated_at = @row[:updated_at]
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def jailed? = @state == :jailed
|
|
74
|
+
def paroled? = @state == :parole
|
|
75
|
+
# An auto-release hands back the entry as it was at the moment the row went away --
|
|
76
|
+
# the only chance the caller gets to report it.
|
|
77
|
+
def released? = @state == :released || @row[:released] == true
|
|
78
|
+
|
|
79
|
+
def location = "#{@file}:#{@line}"
|
|
80
|
+
def to_h = @row.dup
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def normalize_state(value)
|
|
85
|
+
case value.to_s
|
|
86
|
+
when "parole", "paroled" then :parole
|
|
87
|
+
when "released" then :released
|
|
88
|
+
else :jailed
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# What one turn of the parole state machine did. The reporter reads this to say
|
|
94
|
+
# "Failed on day 3 of a 10-run parole" and "this is its 2nd time in jail" without
|
|
95
|
+
# doing arithmetic of its own.
|
|
96
|
+
class Transition
|
|
97
|
+
OUTCOMES = %i[none parole_continues released parole_violation].freeze
|
|
98
|
+
|
|
99
|
+
attr_reader :outcome, :entry, :parole_day, :parole_period, :times_jailed, :parole_violations
|
|
100
|
+
|
|
101
|
+
def initialize(outcome:, entry: nil, parole_day: 0, parole_period: 0,
|
|
102
|
+
times_jailed: nil, parole_violations: nil)
|
|
103
|
+
@outcome = outcome
|
|
104
|
+
@entry = entry
|
|
105
|
+
@parole_day = parole_day.to_i
|
|
106
|
+
@parole_period = parole_period.to_i
|
|
107
|
+
@times_jailed = (times_jailed || entry&.times_jailed).to_i
|
|
108
|
+
@parole_violations = (parole_violations || entry&.parole_violations).to_i
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def none? = @outcome == :none
|
|
112
|
+
def continuing? = @outcome == :parole_continues
|
|
113
|
+
def released? = @outcome == :released
|
|
114
|
+
def violation? = @outcome == :parole_violation
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
attr_reader :config, :storage
|
|
118
|
+
|
|
119
|
+
def initialize(config: Constable.config, storage: Constable.storage)
|
|
120
|
+
@config = config
|
|
121
|
+
@storage = storage
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Consecutive clean runs that earn an automatic release. Floors at 1 -- a period of
|
|
125
|
+
# zero would mean "release on sight", which is not parole.
|
|
126
|
+
def parole_period
|
|
127
|
+
period = @config.respond_to?(:parole_period) ? @config.parole_period.to_i : 0
|
|
128
|
+
period.positive? ? period : 10
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# --- queries ---------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
# The whole docket, jailed and paroled alike, newest first. Assembled from both
|
|
134
|
+
# storage lists and deduplicated, so an adapter that returns everything from #jailed
|
|
135
|
+
# is as correct here as one that filters by state.
|
|
136
|
+
def entries
|
|
137
|
+
rows = Array(@storage.jailed) + Array(@storage.paroled)
|
|
138
|
+
rows.filter_map { |row| Entry.wrap(row) }.uniq(&:identity)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def entry(identity) = Entry.wrap(@storage.jail_entry(identity.to_s))
|
|
142
|
+
|
|
143
|
+
def jailed = entries.select(&:jailed?)
|
|
144
|
+
def paroled = entries.select(&:paroled?)
|
|
145
|
+
|
|
146
|
+
# Deliberately not Storage#jailed?, which is "on the docket at all" -- a paroled test
|
|
147
|
+
# is on the docket and is emphatically not in jail.
|
|
148
|
+
def jailed?(identity) = entry(identity)&.jailed? || false
|
|
149
|
+
def paroled?(identity) = entry(identity)&.paroled? || false
|
|
150
|
+
|
|
151
|
+
def supervised?(identity) = !entry(identity).nil?
|
|
152
|
+
|
|
153
|
+
# The Runner's question. True means: build the instance, run every briefing and
|
|
154
|
+
# witness, then stop short of the investigate body. Setup rot surfaces immediately;
|
|
155
|
+
# the failing assertion stays locked up.
|
|
156
|
+
def skip_body?(identity) = jailed?(identity)
|
|
157
|
+
|
|
158
|
+
# A test passed, then failed, and its content hash never moved. That is a flake, and
|
|
159
|
+
# it is the one route into jail that needs no flag and no human.
|
|
160
|
+
#
|
|
161
|
+
# Ask this *before* recording the current result to flake history, or the "previous"
|
|
162
|
+
# status will be the one being adjudicated.
|
|
163
|
+
def flake_flip?(result)
|
|
164
|
+
return false unless result.failed?
|
|
165
|
+
|
|
166
|
+
@storage.last_status(result.identity).to_s == "passed"
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# --- routes in -------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
# Books a finished Result onto the docket and hands it back decided, so the Runner can
|
|
172
|
+
# keep treating results as its only currency.
|
|
173
|
+
def jail(result, reason: :manual)
|
|
174
|
+
text = reason_text(reason)
|
|
175
|
+
entry = jail_identity(result.identity, label: result.display_label,
|
|
176
|
+
file: result.file, line: result.line, reason: text)
|
|
177
|
+
result.status = :jailed
|
|
178
|
+
result.jail_reason = text
|
|
179
|
+
result.times_jailed = entry&.times_jailed
|
|
180
|
+
result
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Route 1. Recorded with its own reason so `constable jail` reads as an explanation
|
|
184
|
+
# rather than a list.
|
|
185
|
+
def jail_for_flake(result) = jail(result, reason: :flake)
|
|
186
|
+
|
|
187
|
+
# Route 2. Jail mode: a failure is tracked and skipped instead of red.
|
|
188
|
+
def jail_failure(result) = jail(result, reason: :jail_mode)
|
|
189
|
+
|
|
190
|
+
# The primitive under all of the above, for the CLI and for anything holding an
|
|
191
|
+
# identity rather than a Result.
|
|
192
|
+
def jail_identity(identity, label:, file:, line:, reason: :manual)
|
|
193
|
+
Entry.wrap(@storage.jail(identity.to_s, label: label, file: file, line: line,
|
|
194
|
+
reason: reason_text(reason))) || entry(identity)
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# --- the parole state machine ----------------------------------------------
|
|
198
|
+
|
|
199
|
+
# A clean run for a test on parole. `parole_period` of these in a row and it walks --
|
|
200
|
+
# automatically, with no human step, because that is what the period is for.
|
|
201
|
+
def record_pass(identity)
|
|
202
|
+
before = entry(identity)
|
|
203
|
+
return Transition.new(outcome: :none, entry: before, parole_period: parole_period) unless before&.paroled?
|
|
204
|
+
|
|
205
|
+
after = Entry.wrap(@storage.record_parole_pass(identity.to_s))
|
|
206
|
+
day = after&.parole_day&.positive? ? after.parole_day : before.parole_day + 1
|
|
207
|
+
|
|
208
|
+
if after&.released?
|
|
209
|
+
transition(:released, after, day)
|
|
210
|
+
elsif day >= parole_period
|
|
211
|
+
# Belt and braces: an adapter that only counts still gets the release it earned.
|
|
212
|
+
@storage.release(identity.to_s)
|
|
213
|
+
transition(:released, after || before, day)
|
|
214
|
+
else
|
|
215
|
+
transition(:parole_continues, after || before, day)
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# A paroled test failed. Once is enough -- parole exists precisely because the test
|
|
220
|
+
# had not earned trust yet, so there is no leniency and no second look.
|
|
221
|
+
def record_failure(identity)
|
|
222
|
+
before = entry(identity)
|
|
223
|
+
return Transition.new(outcome: :none, entry: before, parole_period: parole_period) unless before&.paroled?
|
|
224
|
+
|
|
225
|
+
day = before.parole_day + 1 # the run it went down on, not the runs it survived
|
|
226
|
+
after = Entry.wrap(@storage.record_parole_violation(identity.to_s))
|
|
227
|
+
after = force_back_to_jail(before) if after.nil? || after.paroled?
|
|
228
|
+
|
|
229
|
+
Transition.new(
|
|
230
|
+
outcome: :parole_violation, entry: after, parole_day: day, parole_period: parole_period,
|
|
231
|
+
times_jailed: after&.times_jailed || (before.times_jailed + 1),
|
|
232
|
+
parole_violations: after&.parole_violations || (before.parole_violations + 1)
|
|
233
|
+
)
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# --- human operations ------------------------------------------------------
|
|
237
|
+
|
|
238
|
+
# `constable jail parole PATH:LINE`. Jail -> parole; the clean-run count starts over.
|
|
239
|
+
def parole(identity) = Entry.wrap(@storage.parole(identity.to_s))
|
|
240
|
+
|
|
241
|
+
# `constable jail release PATH:LINE`. Off the docket entirely, no supervision.
|
|
242
|
+
def release(identity) = @storage.release(identity.to_s) ? true : false
|
|
243
|
+
|
|
244
|
+
# `jail run` never auto-releases and never auto-paroles. One green run proves nothing;
|
|
245
|
+
# it only earns a mention. A human reads this list and decides.
|
|
246
|
+
#
|
|
247
|
+
# Pass the raw Results from the jail run -- do *not* route those through #adjudicate,
|
|
248
|
+
# which would book them straight back onto the docket they came from.
|
|
249
|
+
def candidates_for_release(results)
|
|
250
|
+
Array(results).select { |result| passing?(result) }
|
|
251
|
+
.filter_map { |result| entry(result.identity) }
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# The whole picture after a `jail run`, for the CLI to print.
|
|
255
|
+
def jail_run_report(results)
|
|
256
|
+
results = Array(results)
|
|
257
|
+
{
|
|
258
|
+
candidates: candidates_for_release(results),
|
|
259
|
+
still_failing: results.reject { |r| passing?(r) }.filter_map { |r| entry(r.identity) }
|
|
260
|
+
}
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# --- the Runner's single entry point ---------------------------------------
|
|
264
|
+
|
|
265
|
+
# One call per finished result. Returns the same Result, decided.
|
|
266
|
+
#
|
|
267
|
+
# Call it *before* writing the result to flake history (the flip check reads the
|
|
268
|
+
# previous status) and *after* Warrants has had its say (a warranted result is not a
|
|
269
|
+
# failure, so it never reaches the docket).
|
|
270
|
+
def adjudicate(result, jail_mode: false)
|
|
271
|
+
return result if result.nil?
|
|
272
|
+
|
|
273
|
+
docket = entry(result.identity)
|
|
274
|
+
|
|
275
|
+
return record_result(result) if docket&.paroled?
|
|
276
|
+
return mark_jailed(result) if docket&.jailed?
|
|
277
|
+
return result unless result.failed?
|
|
278
|
+
|
|
279
|
+
if jail_mode
|
|
280
|
+
jail_failure(result)
|
|
281
|
+
elsif flake_flip?(result)
|
|
282
|
+
jail_for_flake(result)
|
|
283
|
+
else
|
|
284
|
+
result
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# Drives the parole machine from a Result and stamps the outcome onto it. A parole
|
|
289
|
+
# violation gets its own status -- it is more urgent news than a plain new failure,
|
|
290
|
+
# because somebody deliberately trusted this test again.
|
|
291
|
+
def record_result(result)
|
|
292
|
+
return result unless paroled?(result.identity)
|
|
293
|
+
|
|
294
|
+
if result.failed?
|
|
295
|
+
transition = record_failure(result.identity)
|
|
296
|
+
result.status = :parole_violation
|
|
297
|
+
result.jail_reason = REASONS[:parole_violation]
|
|
298
|
+
result.parole_day = transition.parole_day
|
|
299
|
+
result.times_jailed = transition.times_jailed
|
|
300
|
+
elsif result.passed?
|
|
301
|
+
result.parole_day = record_pass(result.identity).parole_day
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
result
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
# For a test the Runner skipped because it is on the docket: fills in the reason and
|
|
308
|
+
# the repeat-offender count without touching storage. Nothing here is a new offence.
|
|
309
|
+
def mark_jailed(result)
|
|
310
|
+
docket = entry(result.identity)
|
|
311
|
+
result.status = :jailed
|
|
312
|
+
result.jail_reason = docket&.reason
|
|
313
|
+
result.times_jailed = docket&.times_jailed
|
|
314
|
+
result
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
# Headline counts for the summary line: "2 jailed (1 parole violation)".
|
|
318
|
+
def summary_counts(results)
|
|
319
|
+
results = Array(results)
|
|
320
|
+
{
|
|
321
|
+
jailed: results.count { |r| r.status == :jailed },
|
|
322
|
+
parole_violations: results.count { |r| r.status == :parole_violation },
|
|
323
|
+
on_parole: paroled.size
|
|
324
|
+
}
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
# --- PATH:LINE resolution ---------------------------------------------------
|
|
328
|
+
|
|
329
|
+
# The CLI speaks in file:line; the blotter is keyed by content hash. The docket stores
|
|
330
|
+
# both, so it is the first place to look; loaded investigations are the fallback for a
|
|
331
|
+
# test that is not on the docket yet.
|
|
332
|
+
#
|
|
333
|
+
# Returns an identity String, or nil when nothing matches.
|
|
334
|
+
def resolve(target)
|
|
335
|
+
text = target.to_s.strip
|
|
336
|
+
return nil if text.empty?
|
|
337
|
+
return text if identity_like?(text) && entry(text)
|
|
338
|
+
|
|
339
|
+
file, line = self.class.split_target(text)
|
|
340
|
+
return nil if file.empty?
|
|
341
|
+
|
|
342
|
+
matches = entries.select { |e| self.class.same_path?(e.file, file) }
|
|
343
|
+
matches = matches.select { |e| e.line == line } if line
|
|
344
|
+
return matches.first.identity if matches.any?
|
|
345
|
+
|
|
346
|
+
self.class.registry_identity(file, line)
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
def resolve!(target)
|
|
350
|
+
resolve(target) || raise(Constable::Error, "no test found for #{target.inspect} " \
|
|
351
|
+
"(expected PATH:LINE, e.g. test/cases/users_case.rb:12)")
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
# "test/cases/users_case.rb:12" -> ["test/cases/users_case.rb", 12]
|
|
355
|
+
def self.split_target(target)
|
|
356
|
+
text = target.to_s.strip
|
|
357
|
+
match = text.match(/\A(?<file>.+):(?<line>\d+)\z/)
|
|
358
|
+
match ? [match[:file], match[:line].to_i] : [text, nil]
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
def self.normalize_path(path)
|
|
362
|
+
path.to_s.strip.delete_prefix("#{Constable.root}/").delete_prefix("./")
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
def self.same_path?(left, right) = normalize_path(left) == normalize_path(right)
|
|
366
|
+
|
|
367
|
+
# Falls back to whatever the Registry has loaded, so `constable jail parole` works on
|
|
368
|
+
# a test that has never been on the docket.
|
|
369
|
+
def self.registry_identity(file, line)
|
|
370
|
+
investigations = Constable.registry.investigations
|
|
371
|
+
matches = investigations.select { |inv| same_path?(inv.relative_file, file) }
|
|
372
|
+
matches = matches.select { |inv| inv.line.to_i == line } if line
|
|
373
|
+
matches.first&.identity
|
|
374
|
+
rescue StandardError
|
|
375
|
+
nil
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
private
|
|
379
|
+
|
|
380
|
+
def transition(outcome, entry, day)
|
|
381
|
+
Transition.new(outcome: outcome, entry: entry, parole_day: day, parole_period: parole_period)
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
def force_back_to_jail(before)
|
|
385
|
+
jail_identity(before.identity, label: before.label, file: before.file,
|
|
386
|
+
line: before.line, reason: :parole_violation)
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
def reason_text(reason)
|
|
390
|
+
REASONS.fetch(reason) { reason.to_s.empty? ? REASONS[:manual] : reason.to_s }
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
def passing?(result)
|
|
394
|
+
result.respond_to?(:passed?) ? result.passed? : result.to_s == "passed"
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
def identity_like?(text) = text.match?(/\A[0-9a-f]{8,64}\z/)
|
|
398
|
+
end
|
|
399
|
+
end
|