holdfast 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/LICENSE +21 -0
- data/README.ja.md +235 -0
- data/README.md +191 -0
- data/bin/holdfast +4 -0
- data/lib/holdfast/callers.rb +124 -0
- data/lib/holdfast/change.rb +59 -0
- data/lib/holdfast/check.rb +473 -0
- data/lib/holdfast/cli.rb +105 -0
- data/lib/holdfast/draft.rb +303 -0
- data/lib/holdfast/drawn.rb +186 -0
- data/lib/holdfast/harness.rb +90 -0
- data/lib/holdfast/inputs.rb +105 -0
- data/lib/holdfast/record.rb +132 -0
- data/lib/holdfast/runtime/check_support.rb +666 -0
- data/lib/holdfast/runtime/observe.rb +136 -0
- data/lib/holdfast/runtime/reach.rb +104 -0
- data/lib/holdfast/sequences/propose.rb +1506 -0
- data/lib/holdfast/sequences/run.rb +197 -0
- data/lib/holdfast/sequences/sequences.rb +1325 -0
- data/lib/holdfast/stages/diff.rb +213 -0
- data/lib/holdfast/stages/fill.rb +194 -0
- data/lib/holdfast/stages/fold.rb +256 -0
- data/lib/holdfast/stages/hunt.rb +176 -0
- data/lib/holdfast/stages/observe.rb +109 -0
- data/lib/holdfast/stages/select.rb +94 -0
- data/lib/holdfast/tools.rb +33 -0
- data/lib/holdfast/verdict.rb +69 -0
- data/lib/holdfast/version.rb +5 -0
- data/lib/holdfast.rb +44 -0
- data/skills/holdfast/SKILL.md +96 -0
- metadata +122 -0
|
@@ -0,0 +1,666 @@
|
|
|
1
|
+
# Written by `holdfast check` next to the specs it generates
|
|
2
|
+
# (spec/holdfast/<branch>/holdfast_check.rb), so a kept spec needs nothing else.
|
|
3
|
+
# A suspicion example builds its world from the app's factories, runs one
|
|
4
|
+
# action (or two, interleaved), and fails when `expect` does not hold. When
|
|
5
|
+
# HOLDFAST_CHECK_OUT is set it also writes what it saw there as JSON, so
|
|
6
|
+
# holdfast can tell a failed expectation from a world it could not build.
|
|
7
|
+
require "json"
|
|
8
|
+
|
|
9
|
+
module HoldfastCheck
|
|
10
|
+
# The world or the action could not be run; nothing was checked. +absent+
|
|
11
|
+
# names the method, constant, attribute or route that does not exist, so a
|
|
12
|
+
# base that predates a feature reads as that, not as a failure.
|
|
13
|
+
class CouldNotRun < StandardError
|
|
14
|
+
attr_reader :phase, :absent
|
|
15
|
+
|
|
16
|
+
def initialize(phase, message, absent: nil)
|
|
17
|
+
@phase = phase
|
|
18
|
+
@absent = absent
|
|
19
|
+
super(message)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# The points a race can hold its first action at. A named point holds the
|
|
24
|
+
# first time the held action passes it; `Class#method` is the fifth kind.
|
|
25
|
+
POINTS = {
|
|
26
|
+
"batch_load" => "its first query that returns rows",
|
|
27
|
+
"outside_call" => "its first call to an outside-service client",
|
|
28
|
+
"row_lock" => "its first with_lock or lock!, before the lock is taken",
|
|
29
|
+
"first_write" => "its first INSERT, UPDATE or DELETE"
|
|
30
|
+
}.freeze
|
|
31
|
+
# With no pause named, or batch_load named and never reached, these are
|
|
32
|
+
# tried in turn until one breaks `expect` in a way no serial order does.
|
|
33
|
+
FALLBACKS = %w[outside_call row_lock first_write].freeze
|
|
34
|
+
|
|
35
|
+
def self.hold(gate, point, at)
|
|
36
|
+
return unless gate && gate[:point] == point && !gate[:passed]
|
|
37
|
+
|
|
38
|
+
gate[:passed] = true
|
|
39
|
+
gate[:held_at] = at
|
|
40
|
+
gate[:paused].push(true)
|
|
41
|
+
gate[:go].pop
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.gate = Thread.current[:holdfast_gate]
|
|
45
|
+
|
|
46
|
+
module BatchGate
|
|
47
|
+
def records
|
|
48
|
+
depth = Thread.current[:holdfast_load_depth].to_i
|
|
49
|
+
Thread.current[:holdfast_load_depth] = depth + 1
|
|
50
|
+
rows = begin
|
|
51
|
+
super
|
|
52
|
+
ensure
|
|
53
|
+
Thread.current[:holdfast_load_depth] = depth
|
|
54
|
+
end
|
|
55
|
+
HoldfastCheck.hold(HoldfastCheck.gate, "batch_load", "#{klass.name} load (#{rows.size} rows)") if depth.zero? && rows.any?
|
|
56
|
+
rows
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
ActiveRecord::Relation.prepend(BatchGate)
|
|
60
|
+
|
|
61
|
+
module LockGate
|
|
62
|
+
def with_lock(...)
|
|
63
|
+
HoldfastCheck.hold(HoldfastCheck.gate, "row_lock", "#{self.class.name}##{id} with_lock")
|
|
64
|
+
super
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def lock!(...)
|
|
68
|
+
HoldfastCheck.hold(HoldfastCheck.gate, "row_lock", "#{self.class.name}##{id} lock!")
|
|
69
|
+
super
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
ActiveRecord::Base.prepend(LockGate)
|
|
73
|
+
|
|
74
|
+
module WriteGate
|
|
75
|
+
%i[insert update delete].each do |verb|
|
|
76
|
+
define_method(verb) do |arel, name = nil, *rest, **kw, &blk|
|
|
77
|
+
HoldfastCheck.hold(HoldfastCheck.gate, "first_write", "#{verb.to_s.upcase} (#{name || "sql"})")
|
|
78
|
+
super(arel, name, *rest, **kw, &blk)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def self.watch_writes
|
|
84
|
+
adapter = ActiveRecord::Base.connection.class
|
|
85
|
+
adapter.prepend(WriteGate) unless adapter <= WriteGate
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# The outside-service fakes: a module that holds a client
|
|
89
|
+
# (`PaymentGateway.client = FakePaymentGateway.new`).
|
|
90
|
+
def self.client_holders
|
|
91
|
+
ObjectSpace.each_object(Module).select { _1.name && _1.instance_variable_defined?(:@client) && _1.instance_variable_get(:@client) }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Each order of a race starts from the same outside fakes: each client
|
|
95
|
+
# gets its instance variables back as they were before the first.
|
|
96
|
+
def self.clients
|
|
97
|
+
client_holders.filter_map do |mod|
|
|
98
|
+
client = mod.instance_variable_get(:@client)
|
|
99
|
+
[client, client.instance_variables.to_h { [_1, Marshal.dump(client.instance_variable_get(_1))] }]
|
|
100
|
+
rescue TypeError
|
|
101
|
+
nil
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def self.restore(saved) = saved.each { |client, ivars| ivars.each { client.instance_variable_set(_1, Marshal.load(_2)) } }
|
|
106
|
+
|
|
107
|
+
def self.watch_outside_calls
|
|
108
|
+
client_holders.each do |mod|
|
|
109
|
+
client = mod.instance_variable_get(:@client)
|
|
110
|
+
next if client.singleton_class.include?(OutsideGate)
|
|
111
|
+
|
|
112
|
+
verbs = client.class.public_instance_methods(false)
|
|
113
|
+
label = client.class.name
|
|
114
|
+
gate = Module.new do
|
|
115
|
+
verbs.each do |verb|
|
|
116
|
+
define_method(verb) do |*args, **kw, &blk|
|
|
117
|
+
HoldfastCheck.hold(HoldfastCheck.gate, "outside_call", "#{label}##{verb}")
|
|
118
|
+
super(*args, **kw, &blk)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
client.singleton_class.include(OutsideGate)
|
|
123
|
+
client.singleton_class.prepend(gate)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
OutsideGate = Module.new
|
|
127
|
+
|
|
128
|
+
def self.method_gate(point)
|
|
129
|
+
@method_gates ||= {}
|
|
130
|
+
return if @method_gates[point]
|
|
131
|
+
|
|
132
|
+
owner, sep, name = point.rpartition(/[#.]/)
|
|
133
|
+
raise CouldNotRun.new("pause", "pause #{point.inspect}: name it as #{POINTS.keys.join(", ")}, Class#method or Class.method") if owner.empty?
|
|
134
|
+
|
|
135
|
+
klass = Object.const_get(owner)
|
|
136
|
+
target = sep == "." ? klass.singleton_class : klass
|
|
137
|
+
hook = Module.new do
|
|
138
|
+
define_method(name) do |*args, **kw, &blk|
|
|
139
|
+
HoldfastCheck.hold(HoldfastCheck.gate, point, point)
|
|
140
|
+
super(*args, **kw, &blk)
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
target.prepend(hook)
|
|
144
|
+
@method_gates[point] = true
|
|
145
|
+
rescue NameError => e
|
|
146
|
+
raise CouldNotRun.new("pause", "pause #{point.inspect}: #{e.message}", absent: HoldfastCheck.absent(e))
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# A NameError for a missing constant or method on a real object, an
|
|
150
|
+
# unknown attribute or column, or a route that does not exist: what a tree
|
|
151
|
+
# shows when it predates the feature the suspicion is about. A method
|
|
152
|
+
# called on nil is a bug, not an absence. The attribute errors come first:
|
|
153
|
+
# both are NoMethodErrors with no name and no receiver.
|
|
154
|
+
def self.absent(error)
|
|
155
|
+
case error
|
|
156
|
+
when ActionController::RoutingError then "no route: #{error.message.lines.first.strip}"
|
|
157
|
+
when ActiveModel::UnknownAttributeError then "no column #{error.attribute} on #{error.record.class.name}"
|
|
158
|
+
when ActiveModel::MissingAttributeError
|
|
159
|
+
# Only a write: "missing attribute" on a read is a column the query did
|
|
160
|
+
# not select, which the tree has.
|
|
161
|
+
name = error.message[/\Acan't write unknown attribute [`'"]?(\w+)/, 1]
|
|
162
|
+
"no column #{name}#{" on #{error.instance_variable_get(:@holdfast_owner)}" if error.instance_variable_get(:@holdfast_owner)}" if name
|
|
163
|
+
when NoMethodError
|
|
164
|
+
receiver = begin
|
|
165
|
+
error.receiver
|
|
166
|
+
rescue ArgumentError
|
|
167
|
+
NO_RECEIVER
|
|
168
|
+
end
|
|
169
|
+
if error.name && !receiver.equal?(NO_RECEIVER)
|
|
170
|
+
"no method #{error.name} on #{receiver.is_a?(Module) ? receiver.name : receiver.class.name}" unless receiver.nil?
|
|
171
|
+
elsif (m = error.message.match(/undefined method ['`]([^']+)' for (?:an instance of |class |module )?(\S+)/)) && m[2] != "nil"
|
|
172
|
+
"no method #{m[1]} on #{m[2]}"
|
|
173
|
+
end
|
|
174
|
+
when NameError then "no constant #{error.name}" if error.message.start_with?("uninitialized constant")
|
|
175
|
+
when ActiveRecord::StatementInvalid
|
|
176
|
+
return unless error.cause.class.name.to_s.end_with?("UndefinedColumn", "UndefinedTable")
|
|
177
|
+
|
|
178
|
+
case error.message
|
|
179
|
+
when /column "?(\w+)"? of relation "(\w+)" does not exist/ then "no column #{$1} on #{$2}"
|
|
180
|
+
when /column (\w+)\.(\w+) does not exist/ then "no column #{$2} on #{$1}"
|
|
181
|
+
when /column "(\w+)" does not exist/ then "no column #{$1}"
|
|
182
|
+
when /relation "(\w+)" does not exist/ then "no table #{$1}"
|
|
183
|
+
else "no column or table: #{error.message.lines.first.strip[0, 160]}"
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
NO_RECEIVER = Object.new.freeze
|
|
188
|
+
|
|
189
|
+
# update_columns writes past the attribute methods, so its error names the
|
|
190
|
+
# column but not the record; the class is kept on the error for `absent`.
|
|
191
|
+
module ColumnOwner
|
|
192
|
+
def update_columns(...)
|
|
193
|
+
super
|
|
194
|
+
rescue ActiveModel::MissingAttributeError => e
|
|
195
|
+
e.instance_variable_set(:@holdfast_owner, self.class.name)
|
|
196
|
+
raise
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
ActiveRecord::Base.prepend(ColumnOwner)
|
|
200
|
+
|
|
201
|
+
# One record per factory call. A record made earlier fills a belongs_to
|
|
202
|
+
# the attributes leave out, so `member` then `loan` is that member's loan.
|
|
203
|
+
# Inside `given`, an earlier record is read by its name. A given written
|
|
204
|
+
# as Ruby may return any value; only records are read again.
|
|
205
|
+
class World
|
|
206
|
+
attr_reader :records
|
|
207
|
+
|
|
208
|
+
def initialize(example) = (@example = example) && (@records = {})
|
|
209
|
+
|
|
210
|
+
def make(name, factory: name, traits: [], **attrs)
|
|
211
|
+
klass = FactoryBot.factories.find(factory).build_class
|
|
212
|
+
klass.reflect_on_all_associations(:belongs_to).each do |assoc|
|
|
213
|
+
next if assoc.polymorphic? || assoc.options[:optional] || attrs.key?(assoc.name) || attrs.key?(assoc.foreign_key.to_sym)
|
|
214
|
+
|
|
215
|
+
made = linkable.find { _1.is_a?(assoc.klass) }
|
|
216
|
+
attrs[assoc.name] = made if made
|
|
217
|
+
end
|
|
218
|
+
@records[name] = FactoryBot.create(factory, *traits, **attrs)
|
|
219
|
+
rescue StandardError => e
|
|
220
|
+
shown = attrs.map { |k, v| "#{k}: #{v.is_a?(ActiveRecord::Base) ? "#{v.class.name}##{v.id}" : v.inspect}" }.join(", ")
|
|
221
|
+
raise CouldNotRun.new("given", "given.#{name} (factory :#{factory}#{", #{shown}" unless shown.empty?}): #{e.class}: #{e.message.lines.first.to_s.strip}",
|
|
222
|
+
absent: HoldfastCheck.absent(e))
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# A given written as Ruby: the block returns the record (or any value).
|
|
226
|
+
def build(name, text, &block)
|
|
227
|
+
@records[name] = instance_exec(&block)
|
|
228
|
+
rescue StandardError => e
|
|
229
|
+
raise CouldNotRun.new("given", "given.#{name} (#{text}): #{e.class}: #{e.message.lines.first.to_s.strip}", absent: HoldfastCheck.absent(e))
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# The records made so far, newest first, then what each belongs to, so a
|
|
233
|
+
# hold made after a copy gets that copy's book.
|
|
234
|
+
def linkable
|
|
235
|
+
made = @records.values.flatten.grep(ActiveRecord::Base).reverse
|
|
236
|
+
made + made.flat_map { |r| r.class.reflect_on_all_associations(:belongs_to).reject(&:polymorphic?).filter_map { r.public_send(_1.name) } }
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def fresh = @records.transform_values { |r| r.is_a?(Array) ? r.map { reload(_1) } : reload(r) }
|
|
240
|
+
def reload(value) = value.is_a?(ActiveRecord::Base) && value.persisted? ? (value.class.unscoped.find_by(id: value.id) || value) : value
|
|
241
|
+
def method_missing(name, *args, **kw, &blk) = @records.key?(name) && args.empty? ? @records[name] : @example.send(name, *args, **kw, &blk)
|
|
242
|
+
def respond_to_missing?(name, all = false) = @records.key?(name) || @example.respond_to?(name, true)
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# What `do` and `expect` see: each given record by its name (read again
|
|
246
|
+
# from the database), `result` and `error` (of the one action, or of the
|
|
247
|
+
# last in a race), `results` and `errors` (one per action, in the order the
|
|
248
|
+
# suspicion lists them), `calls(X, :verb)`, and everything the spec has.
|
|
249
|
+
class Scope
|
|
250
|
+
def initialize(example, records, result: nil, error: nil, results: [], errors: [], calls: nil)
|
|
251
|
+
@example, @records, @result, @error, @results, @errors, @calls = example, records, result, error, results, errors, calls
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
attr_reader :result, :error, :results, :errors
|
|
255
|
+
|
|
256
|
+
OWN = %i[@example @records @result @error @results @errors @calls].freeze
|
|
257
|
+
|
|
258
|
+
# The instance variables a `do` set, so `expect` reads `@payment` as
|
|
259
|
+
# `do` left it (a record read again).
|
|
260
|
+
def own_ivars = instance_variables - OWN
|
|
261
|
+
def take_ivars(from, world) = from.own_ivars.each { instance_variable_set(_1, world.reload(from.instance_variable_get(_1))) }
|
|
262
|
+
|
|
263
|
+
def calls(owner, verb) = @calls.of(owner, verb)
|
|
264
|
+
def method_missing(name, *args, **kw, &blk) = @records.key?(name) && args.empty? ? @records[name] : @example.send(name, *args, **kw, &blk)
|
|
265
|
+
def respond_to_missing?(name, all = false) = @records.key?(name) || @example.respond_to?(name, true)
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# Every call to one method of an outside service's client (`PaymentGateway`
|
|
269
|
+
# and `PaymentGateway.client` both name it), counted from the action on.
|
|
270
|
+
class Calls
|
|
271
|
+
def initialize(specs) = (@specs = specs) && (@seen = Hash.new { |h, k| h[k] = [] })
|
|
272
|
+
|
|
273
|
+
def watch
|
|
274
|
+
@specs.each do |owner, verb|
|
|
275
|
+
target = owner.respond_to?(:client) ? owner.client : owner
|
|
276
|
+
seen = @seen
|
|
277
|
+
key = [owner.name, verb.to_sym]
|
|
278
|
+
# "no PaymentGateway charges" names the method `charge`.
|
|
279
|
+
verb = verb.to_s.chomp("s") if !target.respond_to?(verb) && target.respond_to?(verb.to_s.chomp("s"))
|
|
280
|
+
raise CouldNotRun.new("expect", "#{owner.name} has no method #{verb} to count (#{target.class.name})") unless target.respond_to?(verb)
|
|
281
|
+
target.singleton_class.prepend(Module.new do
|
|
282
|
+
define_method(verb) do |*args, **kw, &blk|
|
|
283
|
+
seen[key] << (kw.empty? ? args : args + [kw])
|
|
284
|
+
super(*args, **kw, &blk)
|
|
285
|
+
end
|
|
286
|
+
end)
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def of(owner, verb) = @seen[[owner.name, verb.to_sym]]
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# An action or an expectation line, as the suspicion wrote it. +parts+ are
|
|
294
|
+
# the operands of `a && b` (or `a || b`), each [text, block, observe].
|
|
295
|
+
Act = Data.define(:text, :block)
|
|
296
|
+
Expect = Data.define(:text, :observe, :parts, :block)
|
|
297
|
+
|
|
298
|
+
def act(text, &block) = Act.new(text:, block:)
|
|
299
|
+
def expectation(text, observe: nil, parts: [], &block) = Expect.new(text:, observe:, parts:, block:)
|
|
300
|
+
|
|
301
|
+
# Runs one suspicion: +given+ makes the records (in a World), +acts+ is one
|
|
302
|
+
# action or two (a race at +pause+), +expects+ must all hold afterwards.
|
|
303
|
+
# +calls+ lists the outside-service methods `expect` counts. +shared+ is
|
|
304
|
+
# the example's binding, which holds the locals `do` sets and `expect` reads.
|
|
305
|
+
def holdfast_suspicion(id, given:, acts:, expects:, calls: [], pause: nil, errors: false, shared: nil)
|
|
306
|
+
report = { "id" => id }
|
|
307
|
+
if acts.size == 1
|
|
308
|
+
report.merge!(holdfast_once(given, acts, [0], expects, calls, pause: nil, errors:, shared:))
|
|
309
|
+
else
|
|
310
|
+
report.merge!(holdfast_race(given, acts, expects, calls, pause, shared))
|
|
311
|
+
end
|
|
312
|
+
rescue CouldNotRun => e
|
|
313
|
+
report.merge!({ "result" => "could_not_run", "phase" => e.phase, "error" => e.message, "absent" => e.absent }.compact)
|
|
314
|
+
rescue StandardError, ScriptError => e
|
|
315
|
+
report.merge!("result" => "could_not_run", "phase" => "spec", "error" => HoldfastCheck.first_lines(e))
|
|
316
|
+
ensure
|
|
317
|
+
holdfast_truncate if acts.size > 1
|
|
318
|
+
holdfast_write(report)
|
|
319
|
+
case report["result"]
|
|
320
|
+
when "could_not_run" then raise CouldNotRun.new(report["phase"], report["error"])
|
|
321
|
+
when "confirmed" then raise RSpec::Expectations::ExpectationNotMetError, "suspicion confirmed: #{report["why"]}"
|
|
322
|
+
end
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
# The error's first line and the line of the kept spec it came from, so a
|
|
326
|
+
# row never reads ": ".
|
|
327
|
+
def self.first_lines(error)
|
|
328
|
+
frames = Array(error.backtrace)
|
|
329
|
+
spec = frames.find { _1.match?(%r{spec/holdfast/(?:[\w.-]+/)?\w+_spec\.rb:\d+}) }
|
|
330
|
+
own = frames.first.to_s.include?("holdfast_check.rb") ? " (inside holdfast: #{frames.first[%r{holdfast_check\.rb:\d+}]})" : ""
|
|
331
|
+
at = if spec && (m = spec.match(%r{(\S*spec/holdfast/(?:[\w.-]+/)?\w+_spec\.rb):(\d+)}))
|
|
332
|
+
text = (File.readlines(m[1])[m[2].to_i - 1].to_s.strip rescue "")
|
|
333
|
+
" at #{m[1][%r{spec/holdfast/.*}]}:#{m[2]} `#{text[0, 160]}`"
|
|
334
|
+
end
|
|
335
|
+
"#{error.class}: #{error.message.lines.first.to_s.strip}#{own}#{at}"
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def holdfast_race(given, acts, expects, calls, pause, shared)
|
|
339
|
+
Rails.application.eager_load!
|
|
340
|
+
HoldfastCheck.watch_writes
|
|
341
|
+
named = pause && pause != "batch_load"
|
|
342
|
+
HoldfastCheck.method_gate(pause) if named && !POINTS.key?(pause)
|
|
343
|
+
points = named ? [pause] : ["batch_load", *FALLBACKS]
|
|
344
|
+
clients = HoldfastCheck.clients
|
|
345
|
+
fresh_run = lambda do |order, point, trace: false|
|
|
346
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
347
|
+
holdfast_truncate
|
|
348
|
+
HoldfastCheck.restore(clients)
|
|
349
|
+
holdfast_once(given, order.map { acts[_1] }, order, expects, calls, pause: point, errors: true, trace:, shared:)
|
|
350
|
+
.merge("seconds" => (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started).round(2))
|
|
351
|
+
end
|
|
352
|
+
orders = { "first then second" => fresh_run.([0, 1], nil, trace: true), "second then first" => fresh_run.([1, 0], nil, trace: true) }
|
|
353
|
+
tried = []
|
|
354
|
+
verdict = nil
|
|
355
|
+
points.each do |point|
|
|
356
|
+
held = {
|
|
357
|
+
"first held at #{point}, second runs" => fresh_run.([0, 1], point),
|
|
358
|
+
"second held at #{point}, first runs" => fresh_run.([1, 0], point)
|
|
359
|
+
}
|
|
360
|
+
reached = held.values.any? { _1["held_at"] }
|
|
361
|
+
tried << "#{point}: #{reached ? held.values.filter_map { _1["held_at"] }.uniq.join(", ") : "not reached"}"
|
|
362
|
+
next unless reached
|
|
363
|
+
|
|
364
|
+
verdict = holdfast_race_verdict(orders, held, point)
|
|
365
|
+
orders.merge!(held)
|
|
366
|
+
break if verdict["result"] == "confirmed" || pause == point
|
|
367
|
+
end
|
|
368
|
+
candidates = orders.values_at("first then second", "second then first").flat_map { Array(_1["called"]) }.uniq.first(8)
|
|
369
|
+
unless verdict
|
|
370
|
+
return { "result" => "could_not_run", "phase" => "pause", "orders" => orders, "pauses" => tried,
|
|
371
|
+
"error" => "no action reached #{points.join(", ")}; name a method the first action calls as `pause: Class#method`#{"; candidates: #{candidates.join(", ")}" if candidates.any?}" }
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
orders.each_value { _1.delete("called") }
|
|
375
|
+
if verdict["result"] == "refuted"
|
|
376
|
+
used = tried.reject { _1.end_with?("not reached") }
|
|
377
|
+
verdict = verdict.merge("why" => verdict["serial_fails"] ? "#{verdict["why"]} (held at #{used.join("; ")})" : "each interleaving ended as a serial order ends (held at #{used.join("; ")})")
|
|
378
|
+
end
|
|
379
|
+
verdict.merge("orders" => orders, "pauses" => tried)
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
def holdfast_once(given, acts, positions, expects, call_specs, pause:, errors:, trace: false, shared: nil)
|
|
383
|
+
world = World.new(self)
|
|
384
|
+
world.instance_exec(&given)
|
|
385
|
+
given_ids = world.records.values.flatten.grep(ActiveRecord::Base).map { "#{_1.class.name}##{_1.id}" }
|
|
386
|
+
# A race runs its actions once per order; no order reads the locals the
|
|
387
|
+
# order before it left.
|
|
388
|
+
shared&.local_variables&.each { shared.local_variable_set(_1, nil) }
|
|
389
|
+
calls = Calls.new(call_specs | HoldfastCheck.counted(expects))
|
|
390
|
+
calls.watch
|
|
391
|
+
HoldfastCheck.watch_outside_calls if pause
|
|
392
|
+
called = []
|
|
393
|
+
outcomes = if pause
|
|
394
|
+
holdfast_interleave(world, acts, pause)
|
|
395
|
+
else
|
|
396
|
+
acts.map { |a| holdfast_traced(trace ? called : nil) { holdfast_step(world.fresh, a, errors:) } }
|
|
397
|
+
end
|
|
398
|
+
holdfast_reached!(expects)
|
|
399
|
+
results = Array.new(acts.size)
|
|
400
|
+
errs = Array.new(acts.size)
|
|
401
|
+
positions.each_with_index do |pos, i|
|
|
402
|
+
results[pos] = outcomes[i][:result]
|
|
403
|
+
errs[pos] = outcomes[i][:error]
|
|
404
|
+
end
|
|
405
|
+
scope = Scope.new(self, world.fresh, result: outcomes.last[:result], error: outcomes.map { _1[:error] }.compact.first, results:, errors: errs, calls:)
|
|
406
|
+
outcomes.each { scope.take_ivars(_1[:scope], world) if _1[:scope] }
|
|
407
|
+
shared&.local_variables&.each { shared.local_variable_set(_1, world.reload(shared.local_variable_get(_1))) }
|
|
408
|
+
checked = expects.map { holdfast_expect(scope, _1) }
|
|
409
|
+
why = checked.reject { _1["holds"] }.map { "#{_1["expect"]} does not hold#{": #{_1["failed"].join("; ")}" if _1["failed"]}#{" (observed #{_1["observed"]})" if _1["observed"]}" }.join("; ")
|
|
410
|
+
{ "result" => why.empty? ? "refuted" : "confirmed", "why" => (why unless why.empty?),
|
|
411
|
+
"outcomes" => outcomes.map { _1[:shown] }, "ends" => results.zip(errs).map { |r, e| e ? "raised #{e.class}" : holdfast_show(r) }, "paused" => outcomes.first[:paused], "held_at" => outcomes.first[:held_at],
|
|
412
|
+
"absent" => outcomes.filter_map { _1[:absent] }.first, "checks" => checked, "called" => (called.uniq if trace), "given_ids" => given_ids }.compact
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
# Ruby expectations that name `calls(X, :verb)` count those calls too.
|
|
416
|
+
def self.counted(expects)
|
|
417
|
+
expects.flat_map { |e| e.text.scan(/calls\(\s*([A-Z][\w:]*)\s*,\s*:(\w+)\s*\)/) }.uniq.filter_map do |owner, verb|
|
|
418
|
+
[Object.const_get(owner), verb]
|
|
419
|
+
rescue NameError
|
|
420
|
+
nil
|
|
421
|
+
end
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
# A compound expectation that fails names each operand that failed and
|
|
425
|
+
# what it saw, instead of the whole line.
|
|
426
|
+
def holdfast_expect(scope, e)
|
|
427
|
+
seen = begin
|
|
428
|
+
e.observe && scope.instance_exec(&e.observe)
|
|
429
|
+
rescue StandardError => err
|
|
430
|
+
"#{err.class}: #{err.message}"
|
|
431
|
+
end
|
|
432
|
+
holds = begin
|
|
433
|
+
scope.instance_exec(&e.block) ? true : false
|
|
434
|
+
rescue StandardError => err
|
|
435
|
+
# A value that is nil because the action raised (no response, no
|
|
436
|
+
# result) is how the action ended, not a mistake in the suspicion.
|
|
437
|
+
raised = scope.error || scope.errors.compact.first
|
|
438
|
+
if raised && err.is_a?(NoMethodError) && (err.receiver rescue :none).nil?
|
|
439
|
+
needs = HoldfastCheck.receiver_of(e.text, err.name) || "a value"
|
|
440
|
+
return { "expect" => e.text, "holds" => false, "observed" => "do raised #{holdfast_show(raised)}",
|
|
441
|
+
"failed" => ["do raised #{raised.class}; expect needs #{needs}, which is nil after the raise"] }
|
|
442
|
+
end
|
|
443
|
+
raise CouldNotRun.new("expect", "expect #{e.text.inspect}: #{err.class}: #{err.message.lines.first.to_s.strip}", absent: HoldfastCheck.absent(err))
|
|
444
|
+
end
|
|
445
|
+
failed = unless holds || e.parts.empty?
|
|
446
|
+
e.parts.filter_map do |text, block, observe|
|
|
447
|
+
ok = (scope.instance_exec(&block) rescue false)
|
|
448
|
+
next if ok
|
|
449
|
+
|
|
450
|
+
value = begin
|
|
451
|
+
observe ? scope.instance_exec(&observe) : ok
|
|
452
|
+
rescue StandardError => err
|
|
453
|
+
err
|
|
454
|
+
end
|
|
455
|
+
"`#{text}` fails (observed #{holdfast_show(value)})"
|
|
456
|
+
end
|
|
457
|
+
end
|
|
458
|
+
{ "expect" => e.text, "holds" => holds, "observed" => (holdfast_show(seen) if e.observe), "failed" => failed }.compact
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
# The receiver of the first call to +name+ in an expectation: `response`
|
|
462
|
+
# in `response.status == 200`.
|
|
463
|
+
def self.receiver_of(text, name)
|
|
464
|
+
walk = ->(n) { [n, *n.compact_child_nodes.flat_map(&walk)] }
|
|
465
|
+
walk.(Prism.parse(text).value).find { _1.is_a?(Prism::CallNode) && _1.name == name && _1.receiver }&.receiver&.slice
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
# The app methods an action called, in order, so a race whose pause point
|
|
469
|
+
# nobody reached can name where to pause instead.
|
|
470
|
+
def holdfast_traced(called)
|
|
471
|
+
return yield unless called
|
|
472
|
+
|
|
473
|
+
root = "#{Rails.root}/app/"
|
|
474
|
+
tp = TracePoint.new(:call) do |t|
|
|
475
|
+
next unless t.path.start_with?(root) && called.size < 40
|
|
476
|
+
|
|
477
|
+
owner = t.defined_class
|
|
478
|
+
owner = owner.attached_object if owner.singleton_class?
|
|
479
|
+
called << "#{owner.name}#{t.defined_class.singleton_class? ? "." : "#"}#{t.method_id}" if owner.name
|
|
480
|
+
end
|
|
481
|
+
tp.enable(target_thread: Thread.current) { yield }
|
|
482
|
+
end
|
|
483
|
+
|
|
484
|
+
# A request that matched no route, or that the app turned away before it
|
|
485
|
+
# reached any code (401, 403), checked nothing the suspicion is about,
|
|
486
|
+
# unless `expect` names that status. A 404 the app itself answered (a
|
|
487
|
+
# record it did not find) is how the request ended.
|
|
488
|
+
def holdfast_reached!(expects)
|
|
489
|
+
res = respond_to?(:response) && @integration_session && response
|
|
490
|
+
return unless res.respond_to?(:status) && [401, 403, 404].include?(res.status)
|
|
491
|
+
return if expects.any? { _1.text.include?(res.status.to_s) }
|
|
492
|
+
|
|
493
|
+
req = request
|
|
494
|
+
verb, path = req.request_method, req.path
|
|
495
|
+
raised = req.env["action_dispatch.exception"]
|
|
496
|
+
if res.status == 404 && (raised.is_a?(ActionController::RoutingError) || (raised.nil? && !HoldfastCheck.route?(verb, path)))
|
|
497
|
+
near = HoldfastCheck.routes.select { |v, p| v == verb && p.split("/").last(1) == path.split("/").last(1) }.map { "#{_1} #{_2}" }.first(3)
|
|
498
|
+
raise CouldNotRun.new("do", "#{verb} #{path} matches no route#{"; routes that end the same: #{near.join(", ")}" if near.any?}",
|
|
499
|
+
absent: "no route #{verb} #{path}")
|
|
500
|
+
end
|
|
501
|
+
raise CouldNotRun.new("do", "#{verb} #{path} answered #{res.status} (#{res.body.to_s[0, 120]}); sign in or send the token in `do`, or name #{res.status} in `expect`") if res.status != 404
|
|
502
|
+
end
|
|
503
|
+
|
|
504
|
+
# Rails routes and Grape endpoints, as [verb, path pattern].
|
|
505
|
+
def self.routes
|
|
506
|
+
@routes ||= begin
|
|
507
|
+
rails = Rails.application.routes.routes.filter_map do |r|
|
|
508
|
+
next if r.verb.to_s.empty? && r.app.respond_to?(:app)
|
|
509
|
+
|
|
510
|
+
[r.verb.to_s, r.path.spec.to_s.sub("(.:format)", "")]
|
|
511
|
+
end
|
|
512
|
+
grape = defined?(Grape::API) ? ObjectSpace.each_object(Class).select { _1 < Grape::API::Instance rescue false }.flat_map { |api| api.routes.map { [_1.request_method, _1.path.sub("(.json)", "").sub("(.:format)", "")] } } : []
|
|
513
|
+
(rails + grape).uniq
|
|
514
|
+
end
|
|
515
|
+
end
|
|
516
|
+
|
|
517
|
+
def self.route?(verb, path)
|
|
518
|
+
routes.any? do |v, pattern|
|
|
519
|
+
next false unless v.split("|").include?(verb) || v.empty?
|
|
520
|
+
|
|
521
|
+
Regexp.new("\\A#{Regexp.escape(pattern).gsub(/:\w+/, "[^/]+")}\\z").match?(path)
|
|
522
|
+
end
|
|
523
|
+
end
|
|
524
|
+
|
|
525
|
+
# An error the app's own code raised (a refusal, a nil it did not expect)
|
|
526
|
+
# is how the action ended, and `expect` is checked against it. An error
|
|
527
|
+
# from the suspicion's own line (a misspelt name, a wrong argument list)
|
|
528
|
+
# means the action never ran as written. In a race, or when `expect`
|
|
529
|
+
# names `error` or `raises`, every error is how the action ended, except
|
|
530
|
+
# a name the tree does not have: that tree cannot run the suspicion.
|
|
531
|
+
def holdfast_step(records, act, errors:)
|
|
532
|
+
scope = Scope.new(self, records)
|
|
533
|
+
result = scope.instance_exec(&act.block)
|
|
534
|
+
{ result:, scope:, shown: "#{act.text} returned #{holdfast_show(result)}" }
|
|
535
|
+
rescue StandardError => e
|
|
536
|
+
app = HoldfastCheck.app_error?(e)
|
|
537
|
+
absent = HoldfastCheck.absent(e) unless app
|
|
538
|
+
raise CouldNotRun.new("do", "`#{act.text}` raised #{e.class}: #{e.message.lines.first.to_s.strip}", absent:) if absent || !(errors || app)
|
|
539
|
+
|
|
540
|
+
{ error: e, scope:, shown: "#{act.text.strip.gsub(/\s*\n\s*/, "; ")} raised #{e.class}: #{e.message.lines.first.to_s.strip[0, 300]}" }
|
|
541
|
+
end
|
|
542
|
+
|
|
543
|
+
CALL_SHAPE = /wrong number of arguments|missing keyword|unknown keyword/
|
|
544
|
+
|
|
545
|
+
def self.app_error?(error)
|
|
546
|
+
return false if error.is_a?(ArgumentError) && error.message.match?(CALL_SHAPE)
|
|
547
|
+
|
|
548
|
+
root = Rails.root.to_s
|
|
549
|
+
first = Array(error.backtrace).find { _1.start_with?(root) }
|
|
550
|
+
!!first&.start_with?("#{root}/app/", "#{root}/lib/")
|
|
551
|
+
end
|
|
552
|
+
|
|
553
|
+
# The first action runs on its own thread until it reaches the pause point;
|
|
554
|
+
# the second runs to its end (or until it waits on a lock the first holds);
|
|
555
|
+
# then the first goes on.
|
|
556
|
+
def holdfast_interleave(world, acts, point)
|
|
557
|
+
gate = { point:, paused: Queue.new, go: Queue.new, passed: false }
|
|
558
|
+
first_records, second_records = world.fresh, world.fresh
|
|
559
|
+
outcomes = []
|
|
560
|
+
t1 = Thread.new do
|
|
561
|
+
Thread.current[:holdfast_gate] = gate
|
|
562
|
+
ActiveRecord::Base.connection_pool.with_connection { outcomes[0] = holdfast_step(first_records, acts[0], errors: true) }
|
|
563
|
+
rescue CouldNotRun => e
|
|
564
|
+
outcomes[0] = { error: e, shown: e.message, could_not_run: e }
|
|
565
|
+
ensure
|
|
566
|
+
gate[:paused].push(:done)
|
|
567
|
+
end
|
|
568
|
+
reached = gate[:paused].pop == true
|
|
569
|
+
backend = Queue.new
|
|
570
|
+
t2 = Thread.new do
|
|
571
|
+
ActiveRecord::Base.connection_pool.with_connection do |conn|
|
|
572
|
+
backend << (conn.raw_connection.backend_pid if conn.raw_connection.respond_to?(:backend_pid))
|
|
573
|
+
outcomes[1] = holdfast_step(second_records, acts[1], errors: true)
|
|
574
|
+
end
|
|
575
|
+
rescue CouldNotRun => e
|
|
576
|
+
outcomes[1] = { error: e, shown: e.message, could_not_run: e }
|
|
577
|
+
ensure
|
|
578
|
+
backend << nil
|
|
579
|
+
end
|
|
580
|
+
waited = HoldfastCheck.blocked?(t2, backend.pop)
|
|
581
|
+
gate[:go].push(true)
|
|
582
|
+
[t1, t2].each { raise CouldNotRun.new("race", "the two actions did not finish within 20 s (a deadlock?)") unless _1.join(20) }
|
|
583
|
+
outcomes.each { raise _1[:could_not_run] if _1[:could_not_run] }
|
|
584
|
+
outcomes[0] = outcomes[0].merge(held_at: (gate[:held_at] if reached),
|
|
585
|
+
paused: reached ? "held at #{gate[:held_at]}#{"; the second waited on a lock" if waited}" : "never reached #{point}")
|
|
586
|
+
outcomes
|
|
587
|
+
end
|
|
588
|
+
|
|
589
|
+
# Whether the second action is still running after a second, or (on
|
|
590
|
+
# PostgreSQL) sooner, as soon as its connection waits on a lock the held
|
|
591
|
+
# action has: a fixed wait cost a second per held order.
|
|
592
|
+
def self.blocked?(thread, pid, limit: 1.0)
|
|
593
|
+
return thread.join(limit).nil? unless pid
|
|
594
|
+
|
|
595
|
+
conn = ActiveRecord::Base.connection
|
|
596
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + limit
|
|
597
|
+
until thread.join(0.01)
|
|
598
|
+
return true if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
|
|
599
|
+
return true if conn.select_value("SELECT 1 FROM pg_locks WHERE pid = #{Integer(pid)} AND NOT granted LIMIT 1")
|
|
600
|
+
end
|
|
601
|
+
false
|
|
602
|
+
end
|
|
603
|
+
|
|
604
|
+
# A race is confirmed when an interleaved order breaks `expect` and ends in
|
|
605
|
+
# a way neither serial order ends: what each action did and what `expect`
|
|
606
|
+
# saw. An interleaving that ends like a serial order is only an order.
|
|
607
|
+
def holdfast_race_verdict(serial, held, point)
|
|
608
|
+
reached = held.select { |_, run| run["held_at"] }
|
|
609
|
+
# A serial order that fails `expect` means the expectation is not one a
|
|
610
|
+
# race can break: only an end no serial order reaches is evidence then.
|
|
611
|
+
failing = serial.find { |_, run| run["result"] == "confirmed" }
|
|
612
|
+
broken = reached.find { |_, run| run["result"] == "confirmed" && serial.values.none? { HoldfastCheck.end_of(_1) == HoldfastCheck.end_of(run) } }
|
|
613
|
+
if broken && failing
|
|
614
|
+
order, run = broken
|
|
615
|
+
{ "result" => "confirmed", "order" => order, "pause_used" => "#{point} (#{run["held_at"]})",
|
|
616
|
+
"why" => "#{order} (held at #{run["held_at"]}) ends #{HoldfastCheck.end_text(run)}, an end no serial order reaches " \
|
|
617
|
+
"(#{serial.map { |label, r| "#{label} ends #{HoldfastCheck.end_text(r)}" }.join("; ")}); serial order #{failing[0].inspect} also fails expect, so this end is the evidence" }
|
|
618
|
+
elsif broken
|
|
619
|
+
{ "result" => "confirmed", "order" => broken[0], "pause_used" => "#{point} (#{broken[1]["held_at"]})",
|
|
620
|
+
"why" => "#{broken[0]} (held at #{broken[1]["held_at"]}): #{broken[1]["why"]}, an end neither serial order reaches" }
|
|
621
|
+
elsif failing
|
|
622
|
+
{ "result" => "refuted", "pause_used" => point, "serial_fails" => true,
|
|
623
|
+
"why" => "refuted as a race: serial order #{failing[0].inspect} also fails expect (#{failing[1]["why"]}), and each interleaving ends as a serial order ends; write the end no serial order allows" }
|
|
624
|
+
else
|
|
625
|
+
{ "result" => "refuted", "pause_used" => point, "why" => "each interleaving that reached #{point} ended as a serial order ends" }
|
|
626
|
+
end
|
|
627
|
+
end
|
|
628
|
+
|
|
629
|
+
# How a run ended: each action's value (or the class it raised) and what
|
|
630
|
+
# `expect` saw. A record the actions made is named by its class only and a
|
|
631
|
+
# time by nothing, since ids and clocks differ from one order to the next
|
|
632
|
+
# without the end differing; a given record keeps its id.
|
|
633
|
+
TIME = /\d{4}-\d\d-\d\d[ T]\d\d:\d\d:\d\d(?:\.\d+)?(?: ?(?:UTC|Z|[+-]\d\d:?\d\d))*/
|
|
634
|
+
def self.end_of(run)
|
|
635
|
+
given = Array(run["given_ids"])
|
|
636
|
+
[run["ends"], run["checks"].map { [_1["holds"], _1["observed"], _1["failed"]] }].to_s
|
|
637
|
+
.gsub(/\b([A-Z]\w*(?:::[A-Z]\w*)*)#(\d+)/) { given.include?($&) ? $& : "#{$1}#new" }.gsub(TIME, "<time>")
|
|
638
|
+
end
|
|
639
|
+
|
|
640
|
+
def self.end_text(run) = "[#{Array(run["ends"]).join(", ")}; #{run["checks"].map { "#{_1["expect"]} #{_1["holds"] ? "holds" : "fails"}#{" (#{_1["observed"]})" if _1["observed"]}" }.join("; ")}]"
|
|
641
|
+
|
|
642
|
+
def holdfast_show(value)
|
|
643
|
+
case value
|
|
644
|
+
when ActiveRecord::Base
|
|
645
|
+
state = %w[status state].find { value.has_attribute?(_1) }
|
|
646
|
+
"#{value.class.name}##{value.id}#{"(#{state}: #{value[state].inspect})" if state}"
|
|
647
|
+
when Array then value.size <= 3 ? "[#{value.map { holdfast_show(_1) }.join(", ")}]" : "#{value.size} items"
|
|
648
|
+
when Hash then value.inspect[0, 160]
|
|
649
|
+
when nil, true, false, Numeric, Symbol then value.inspect
|
|
650
|
+
when String then value.inspect[0, 160]
|
|
651
|
+
when Exception then "#{value.class}: #{value.message.lines.first.to_s.strip[0, 300]}"
|
|
652
|
+
else value.inspect.then { _1.size <= 160 && !_1.start_with?("#<") ? _1 : value.class.name }
|
|
653
|
+
end
|
|
654
|
+
end
|
|
655
|
+
|
|
656
|
+
def holdfast_truncate
|
|
657
|
+
conn = ActiveRecord::Base.connection
|
|
658
|
+
tables = conn.tables - %w[schema_migrations ar_internal_metadata]
|
|
659
|
+
conn.execute("TRUNCATE #{tables.map { conn.quote_table_name(_1) }.join(", ")} RESTART IDENTITY CASCADE") if tables.any?
|
|
660
|
+
end
|
|
661
|
+
|
|
662
|
+
def holdfast_write(report)
|
|
663
|
+
dir = ENV["HOLDFAST_CHECK_OUT"] or return
|
|
664
|
+
File.write(File.join(dir, "#{report["id"]}.json"), JSON.generate(report))
|
|
665
|
+
end
|
|
666
|
+
end
|