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.
@@ -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