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.
Files changed (76) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +88 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +515 -0
  5. data/exe/constable +7 -0
  6. data/lib/constable/case.rb +336 -0
  7. data/lib/constable/cli.rb +475 -0
  8. data/lib/constable/cold_case/minitest.rb +342 -0
  9. data/lib/constable/cold_case/rspec.rb +334 -0
  10. data/lib/constable/cold_case.rb +280 -0
  11. data/lib/constable/config.rb +125 -0
  12. data/lib/constable/coverage.rb +951 -0
  13. data/lib/constable/diff.rb +212 -0
  14. data/lib/constable/dsl.rb +833 -0
  15. data/lib/constable/identity.rb +121 -0
  16. data/lib/constable/importer/modernizer.rb +860 -0
  17. data/lib/constable/importer/reopener.rb +468 -0
  18. data/lib/constable/importer.rb +51 -0
  19. data/lib/constable/investigation.rb +67 -0
  20. data/lib/constable/isolation.rb +171 -0
  21. data/lib/constable/jail.rb +399 -0
  22. data/lib/constable/log_router.rb +197 -0
  23. data/lib/constable/matchers.rb +834 -0
  24. data/lib/constable/order_audit.rb +130 -0
  25. data/lib/constable/rails_support.rb +213 -0
  26. data/lib/constable/railtie.rb +36 -0
  27. data/lib/constable/registry.rb +57 -0
  28. data/lib/constable/reporter.rb +625 -0
  29. data/lib/constable/result.rb +149 -0
  30. data/lib/constable/runner.rb +697 -0
  31. data/lib/constable/selection.rb +205 -0
  32. data/lib/constable/storage/adapter.rb +91 -0
  33. data/lib/constable/storage/mysql_adapter.rb +125 -0
  34. data/lib/constable/storage/postgres_adapter.rb +125 -0
  35. data/lib/constable/storage/sqlite_adapter.rb +84 -0
  36. data/lib/constable/storage.rb +847 -0
  37. data/lib/constable/version.rb +5 -0
  38. data/lib/constable/warrants.rb +290 -0
  39. data/lib/constable-rails.rb +16 -0
  40. data/lib/constable.rb +151 -0
  41. data/lib/generators/constable/base.rb +99 -0
  42. data/lib/generators/constable/channel/channel_generator.rb +20 -0
  43. data/lib/generators/constable/channel/templates/channel_case.rb.tt +29 -0
  44. data/lib/generators/constable/controller/controller_generator.rb +25 -0
  45. data/lib/generators/constable/controller/templates/controller_case.rb.tt +32 -0
  46. data/lib/generators/constable/generator/generator_generator.rb +31 -0
  47. data/lib/generators/constable/generator/templates/generator_case.rb.tt +28 -0
  48. data/lib/generators/constable/helper/helper_generator.rb +23 -0
  49. data/lib/generators/constable/helper/templates/helper_case.rb.tt +19 -0
  50. data/lib/generators/constable/import_generator.rb +137 -0
  51. data/lib/generators/constable/install_generator.rb +188 -0
  52. data/lib/generators/constable/integration/integration_generator.rb +27 -0
  53. data/lib/generators/constable/integration/templates/request_case.rb.tt +22 -0
  54. data/lib/generators/constable/job/job_generator.rb +20 -0
  55. data/lib/generators/constable/job/templates/job_case.rb.tt +33 -0
  56. data/lib/generators/constable/mailbox/mailbox_generator.rb +20 -0
  57. data/lib/generators/constable/mailbox/templates/mailbox_case.rb.tt +26 -0
  58. data/lib/generators/constable/mailer/mailer_generator.rb +32 -0
  59. data/lib/generators/constable/mailer/templates/mailer_case.rb.tt +34 -0
  60. data/lib/generators/constable/mailer/templates/preview.rb.tt +14 -0
  61. data/lib/generators/constable/model/model_generator.rb +31 -0
  62. data/lib/generators/constable/model/templates/model_case.rb.tt +37 -0
  63. data/lib/generators/constable/resource/resource_generator.rb +27 -0
  64. data/lib/generators/constable/scaffold/scaffold_generator.rb +42 -0
  65. data/lib/generators/constable/scaffold/templates/api_controller_case.rb.tt +54 -0
  66. data/lib/generators/constable/scaffold/templates/controller_case.rb.tt +70 -0
  67. data/lib/generators/constable/scaffold/templates/system_case.rb.tt +53 -0
  68. data/lib/generators/constable/system/system_generator.rb +20 -0
  69. data/lib/generators/constable/system/templates/system_case.rb.tt +18 -0
  70. data/lib/generators/constable/templates/authenticatable.rb.tt +31 -0
  71. data/lib/generators/constable/templates/case_helper.rb.tt +179 -0
  72. data/lib/generators/constable/templates/config.yml.tt +67 -0
  73. data/lib/generators/constable/templates/example_case.rb.tt +56 -0
  74. data/lib/generators/constable/templates/matchers.rb.tt +36 -0
  75. data/lib/generators/constable/templates/rubocop.yml.tt +12 -0
  76. metadata +209 -0
@@ -0,0 +1,833 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require "time"
5
+ require "ripper"
6
+
7
+ module Constable
8
+ # The runtime DSL -- everything an investigator can reach for from inside an
9
+ # `investigate` or `briefing` block that isn't an assertion matcher.
10
+ #
11
+ # Three of these methods exist to remove a source of nondeterminism (`freeze_time`,
12
+ # `travel_to`, `stub_network!`), one exists only under supervision (`wait_for`), and
13
+ # one is the single sanctioned way to bend the rules (`unsafe`). Every rule bent
14
+ # through `unsafe` is reported, every run, without exception -- philosophy point 4.
15
+ #
16
+ # Nothing here requires Rails. ActiveSupport is used when it happens to be loaded and
17
+ # is never required by us: a `:unit` tier run has to boot without an app.
18
+ #
19
+ # The Runner must call {#_constable_dsl_teardown} after every investigation. That one
20
+ # call unfreezes time, lifts the network guard and clears any lingering unsafe state.
21
+ module DSL
22
+ UNSAFE_KEY = :constable_unsafe_stack
23
+
24
+ class << self
25
+ # Testing seam. :auto prefers ActiveSupport's time helpers when they are already
26
+ # loaded; :standalone forces Constable's own stubs even in a Rails app.
27
+ attr_accessor :time_strategy
28
+
29
+ # True while control is inside an `unsafe` block on this thread. The sleep guard,
30
+ # `wait_for` and the network guard all consult this; so may anything else that
31
+ # wants to know whether the rules are currently suspended.
32
+ def unsafe?
33
+ !unsafe_stack.empty?
34
+ end
35
+
36
+ # The reason attached to the innermost active `unsafe` block, or nil.
37
+ def unsafe_reason
38
+ unsafe_stack.last&.fetch(:reason, nil)
39
+ end
40
+
41
+ # The file:line of the innermost active `unsafe` block, or nil.
42
+ def unsafe_location
43
+ unsafe_stack.last&.fetch(:location, nil)
44
+ end
45
+
46
+ def unsafe_stack
47
+ Thread.current[UNSAFE_KEY] ||= []
48
+ end
49
+
50
+ def clear_unsafe!
51
+ Thread.current[UNSAFE_KEY] = nil
52
+ end
53
+
54
+ # ActiveSupport is a guest, not a dependency: we look for it, we never load it.
55
+ def activesupport_time_helpers?
56
+ return false if time_strategy == :standalone
57
+
58
+ defined?(::ActiveSupport::Testing::TimeHelpers) ? true : false
59
+ rescue NameError
60
+ false
61
+ end
62
+ end
63
+
64
+ self.time_strategy = :auto
65
+
66
+ # ------------------------------------------------------------------------------
67
+ # Time
68
+ # ------------------------------------------------------------------------------
69
+
70
+ # Freezes the clock. With a block, only for the block; without one, until the end of
71
+ # the test, at which point the Runner's teardown call unfreezes it. There is no way
72
+ # to leave a test with the clock still stopped.
73
+ def freeze_time(time = ::Time.now, &)
74
+ travel_to(time, &)
75
+ end
76
+
77
+ # Moves the clock to a specific point. Same lifetime rules as freeze_time.
78
+ def travel_to(time, &)
79
+ _constable_time_machine.travel_to(time, &)
80
+ end
81
+
82
+ # Puts the clock back. Safe to call when time was never frozen.
83
+ def travel_back
84
+ _constable_time_machine.travel_back
85
+ end
86
+ alias unfreeze_time travel_back
87
+
88
+ def time_frozen?
89
+ _constable_time_machine.frozen?
90
+ end
91
+
92
+ # ------------------------------------------------------------------------------
93
+ # Network
94
+ # ------------------------------------------------------------------------------
95
+
96
+ # Bars the door: any real outbound HTTP from here to the end of the test raises,
97
+ # naming the host it tried to reach. Idempotent -- call it in a briefing and again
98
+ # in an investigation and nothing doubles up.
99
+ def stub_network!
100
+ NetworkGuard.enable!
101
+ true
102
+ end
103
+
104
+ def network_stubbed?
105
+ NetworkGuard.enabled?
106
+ end
107
+
108
+ # ------------------------------------------------------------------------------
109
+ # The escape hatch
110
+ # ------------------------------------------------------------------------------
111
+
112
+ # Suppresses the runtime guards for one call, and says so out loud. Always.
113
+ #
114
+ # unsafe { sleep(0.1) } # testing an actual timeout path, not a code smell
115
+ #
116
+ # With no explicit reason we read the adjacent comment off the call site, because
117
+ # the reporter prints it and a bare "unsafe block" tells a reviewer nothing.
118
+ def unsafe(reason = nil, &block)
119
+ raise ::Constable::Error, "unsafe requires a block -- there is nothing to make an exception for" unless block
120
+
121
+ location = caller_locations(1, 1)&.first
122
+ site = SourceSite.at(location)
123
+ reason ||= site.reason
124
+
125
+ ::Constable.warn!(site.warning_message(reason), location: site.location, kind: :unsafe)
126
+
127
+ DSL.unsafe_stack.push(reason: reason, location: site.location)
128
+ begin
129
+ block.call
130
+ ensure
131
+ DSL.unsafe_stack.pop
132
+ end
133
+ end
134
+
135
+ def unsafe?
136
+ DSL.unsafe?
137
+ end
138
+
139
+ # A bare sleep is the single most common source of a slow, flaky suite. The linter
140
+ # catches it statically; this catches it at runtime when the linter was bypassed.
141
+ def sleep(seconds = nil)
142
+ unless DSL.unsafe?
143
+ raise ::Constable::Error, <<~MSG.strip
144
+ Bare sleep in a native case. A sleep is a guess about timing, and a guess about
145
+ timing is a flake waiting for a slow CI box.
146
+
147
+ Freeze the clock with `freeze_time` / `travel_to`, or drive the async thing to a
148
+ deterministic finish. If you are genuinely testing a real timeout path, say so:
149
+
150
+ unsafe { sleep(#{seconds.inspect}) } # why this test needs real elapsed time
151
+ MSG
152
+ end
153
+
154
+ seconds.nil? ? Kernel.sleep : Kernel.sleep(seconds)
155
+ end
156
+
157
+ # Bounded polling, for something genuinely asynchronous that cannot be driven to a
158
+ # finish. Legal only inside `unsafe`, because a retry loop is precisely the habit
159
+ # this framework exists to remove.
160
+ def wait_for(timeout: 2, interval: 0.05)
161
+ unless DSL.unsafe?
162
+ raise ::Constable::Error, <<~MSG.strip
163
+ wait_for outside an `unsafe` block.
164
+
165
+ Retry-until-it-passes is exactly what Constable exists to eliminate: it converts a
166
+ real race into a slow test that fails only on someone else's machine. Make the
167
+ thing deterministic instead -- freeze the clock, run the job inline, await the
168
+ worker, assert on the state you control.
169
+
170
+ If this is a genuine, irreducible asynchrony, ask for it explicitly and take the
171
+ warning that comes with it:
172
+
173
+ unsafe { wait_for { page.has_css?(".done") } } # real browser paint, nothing to await
174
+ MSG
175
+ end
176
+
177
+ raise ::Constable::Error, "wait_for requires a block to poll" unless block_given?
178
+
179
+ deadline = _constable_monotonic + timeout.to_f
180
+ attempts = 0
181
+ last_error = nil
182
+
183
+ loop do
184
+ attempts += 1
185
+ begin
186
+ value = yield
187
+ return value if value
188
+ rescue StandardError => e
189
+ last_error = e
190
+ end
191
+
192
+ break if _constable_monotonic >= deadline
193
+
194
+ Kernel.sleep(interval.to_f)
195
+ end
196
+
197
+ message = "wait_for gave up after #{timeout}s (#{attempts} attempt#{"s" unless attempts == 1})"
198
+ message += ": the block never returned a truthy value" unless last_error
199
+ message += "; last error was #{last_error.class}: #{last_error.message}" if last_error
200
+ raise ::Constable::Error, message
201
+ end
202
+
203
+ # ------------------------------------------------------------------------------
204
+ # Assertion primitives
205
+ #
206
+ # `attest` is sugar; these are always here underneath it. Every failure names both
207
+ # sides -- "Expected X, got Y" -- because a failure message that says "assertion
208
+ # failed" costs the reader a round trip to the source.
209
+ # ------------------------------------------------------------------------------
210
+
211
+ def assertion_count
212
+ @assertion_count ||= 0
213
+ end
214
+ alias _constable_assertion_count assertion_count
215
+
216
+ def assert(value, message = nil)
217
+ _constable_assertion!
218
+ return true if value
219
+
220
+ _constable_fail(message || "Expected a truthy value, got #{_constable_show(value)}.")
221
+ end
222
+
223
+ def refute(value, message = nil)
224
+ _constable_assertion!
225
+ return true unless value
226
+
227
+ _constable_fail(message || "Expected a falsey value, got #{_constable_show(value)}.")
228
+ end
229
+
230
+ def assert_equal(expected, actual, message = nil)
231
+ _constable_assertion!
232
+ return true if expected == actual
233
+
234
+ _constable_fail(
235
+ message || "Expected #{_constable_show(expected)}, got #{_constable_show(actual)}.",
236
+ context: _constable_comparison_context(expected, actual)
237
+ )
238
+ end
239
+
240
+ def refute_equal(unexpected, actual, message = nil)
241
+ _constable_assertion!
242
+ return true unless unexpected == actual
243
+
244
+ _constable_fail(message || "Expected something other than #{_constable_show(unexpected)}, got it anyway.")
245
+ end
246
+
247
+ def assert_nil(actual, message = nil)
248
+ _constable_assertion!
249
+ return true if actual.nil?
250
+
251
+ _constable_fail(message || "Expected nil, got #{_constable_show(actual)}.")
252
+ end
253
+
254
+ def refute_nil(actual, message = nil)
255
+ _constable_assertion!
256
+ return true unless actual.nil?
257
+
258
+ _constable_fail(message || "Expected a value, got nil.")
259
+ end
260
+
261
+ def assert_empty(collection, message = nil)
262
+ _constable_assertion!
263
+ unless collection.respond_to?(:empty?)
264
+ _constable_fail(message ||
265
+ "Expected #{_constable_show(collection)} to be empty, but #{collection.class} has no #empty?.")
266
+ end
267
+ return true if collection.empty?
268
+
269
+ size = collection.respond_to?(:size) ? collection.size : nil
270
+ held = if size
271
+ "#{size} #{size == 1 ? "entry" : "entries"}"
272
+ else
273
+ "something"
274
+ end
275
+ _constable_fail(
276
+ message || "Expected #{_constable_show(collection)} to be empty, but it holds #{held}.",
277
+ context: _constable_inspect(collection, limit: 1_000)
278
+ )
279
+ end
280
+
281
+ def assert_includes(collection, item, message = nil)
282
+ _constable_assertion!
283
+ unless collection.respond_to?(:include?)
284
+ _constable_fail(message ||
285
+ "Expected #{_constable_show(collection)} to include #{_constable_show(item)}, " \
286
+ "but #{collection.class} has no #include?.")
287
+ end
288
+ return true if collection.include?(item)
289
+
290
+ _constable_fail(
291
+ message || "Expected #{_constable_show(collection)} to include #{_constable_show(item)}, and it does not.",
292
+ context: _constable_inspect(collection, limit: 1_000)
293
+ )
294
+ end
295
+
296
+ def refute_includes(collection, item, message = nil)
297
+ _constable_assertion!
298
+ return true unless collection.respond_to?(:include?) && collection.include?(item)
299
+
300
+ _constable_fail(
301
+ message || "Expected #{_constable_show(collection)} not to include #{_constable_show(item)}, but it does.",
302
+ context: _constable_inspect(collection, limit: 1_000)
303
+ )
304
+ end
305
+
306
+ # Returns the exception, so the caller can go on to inspect its message or attributes.
307
+ def assert_raises(*expected, &block)
308
+ message = expected.pop if expected.last.is_a?(::String)
309
+ expected = [::StandardError] if expected.empty?
310
+ _constable_assertion!
311
+
312
+ raise ::Constable::Error, "assert_raises requires a block" unless block
313
+
314
+ names = expected.map { |klass| klass.is_a?(::Module) ? klass.name : klass.inspect }.join(" or ")
315
+
316
+ begin
317
+ block.call
318
+ rescue ::Exception => e # rubocop:disable Lint/RescueException -- classified by hand just below
319
+ raise if e.is_a?(::SystemExit) || e.is_a?(::Interrupt) || e.is_a?(::SignalException)
320
+ # An assertion failing inside the block is that assertion's news, not ours --
321
+ # unless the caller genuinely came here to catch one.
322
+ raise if e.is_a?(::Constable::AssertionFailed) &&
323
+ expected.none? { |k| k.is_a?(::Module) && k <= ::Constable::Error }
324
+ return e if expected.any? { |klass| klass.is_a?(::Module) && e.is_a?(klass) }
325
+
326
+ _constable_fail(
327
+ message || "Expected #{names} to be raised, got #{e.class}: #{e.message}",
328
+ context: ::Constable::Backtrace.clean(e.backtrace).join("\n")
329
+ )
330
+ end
331
+
332
+ _constable_fail(message || "Expected #{names} to be raised, but the block completed without raising.")
333
+ end
334
+
335
+ def assert_predicate(object, predicate, message = nil)
336
+ _constable_assertion!
337
+ unless object.respond_to?(predicate)
338
+ _constable_fail(message ||
339
+ "Expected #{_constable_show(object)} to answer ##{predicate}, " \
340
+ "but #{object.class} does not respond to it.")
341
+ end
342
+ return true if object.public_send(predicate)
343
+
344
+ _constable_fail(message ||
345
+ "Expected #{_constable_show(object)} to be #{predicate}, but ##{predicate} returned false.")
346
+ end
347
+
348
+ def refute_predicate(object, predicate, message = nil)
349
+ _constable_assertion!
350
+ return true unless object.respond_to?(predicate) && object.public_send(predicate)
351
+
352
+ _constable_fail(message ||
353
+ "Expected #{_constable_show(object)} not to be #{predicate}, but ##{predicate} returned true.")
354
+ end
355
+
356
+ def assert_match(pattern, string, message = nil)
357
+ _constable_assertion!
358
+ regexp = pattern.is_a?(::Regexp) ? pattern : ::Regexp.new(::Regexp.escape(pattern.to_s))
359
+ return true if regexp.match?(string.to_s)
360
+
361
+ _constable_fail(
362
+ message || "Expected #{_constable_show(string)} to match #{regexp.inspect}, and it does not.",
363
+ context: _constable_inspect(string, limit: 1_000)
364
+ )
365
+ end
366
+
367
+ def refute_match(pattern, string, message = nil)
368
+ _constable_assertion!
369
+ regexp = pattern.is_a?(::Regexp) ? pattern : ::Regexp.new(::Regexp.escape(pattern.to_s))
370
+ return true unless regexp.match?(string.to_s)
371
+
372
+ _constable_fail(message || "Expected #{_constable_show(string)} not to match #{regexp.inspect}, but it does.")
373
+ end
374
+
375
+ # Rails-shaped: the expression may be a String evaluated in the block's own binding,
376
+ # anything callable, or an Array of either.
377
+ def assert_difference(expression, difference = 1, message = nil, &block)
378
+ raise ::Constable::Error, "assert_difference requires a block" unless block
379
+
380
+ expressions = expression.is_a?(::Array) ? expression : [expression]
381
+ before = expressions.map { |exp| _constable_evaluate(exp, block) }
382
+
383
+ result = block.call
384
+
385
+ expressions.each_with_index do |exp, index|
386
+ _constable_assertion!
387
+ after = _constable_evaluate(exp, block)
388
+ actual = after - before[index]
389
+ next if actual == difference
390
+
391
+ _constable_fail(
392
+ message || "Expected #{_constable_describe(exp)} to change by #{difference}, but it changed by #{actual} " \
393
+ "(#{_constable_show(before[index])} → #{_constable_show(after)})."
394
+ )
395
+ end
396
+
397
+ result
398
+ end
399
+
400
+ def assert_no_difference(expression, message = nil, &block)
401
+ raise ::Constable::Error, "assert_no_difference requires a block" unless block
402
+
403
+ expressions = expression.is_a?(::Array) ? expression : [expression]
404
+ before = expressions.map { |exp| _constable_evaluate(exp, block) }
405
+
406
+ result = block.call
407
+
408
+ expressions.each_with_index do |exp, index|
409
+ _constable_assertion!
410
+ after = _constable_evaluate(exp, block)
411
+ next if after == before[index]
412
+
413
+ _constable_fail(
414
+ message || "Expected #{_constable_describe(exp)} not to change, but it changed by " \
415
+ "#{after - before[index]} (#{_constable_show(before[index])} → #{_constable_show(after)})."
416
+ )
417
+ end
418
+
419
+ result
420
+ end
421
+
422
+ # ------------------------------------------------------------------------------
423
+ # Runner interface
424
+ # ------------------------------------------------------------------------------
425
+
426
+ # The single cleanup entry point. The Runner calls this after every investigation,
427
+ # passed or failed, so no test can hand the next one a stopped clock, a barred
428
+ # network or a half-open unsafe block.
429
+ def _constable_dsl_teardown
430
+ @_constable_time_machine&.travel_back
431
+ @_constable_time_machine = nil
432
+ NetworkGuard.disable!
433
+ DSL.clear_unsafe!
434
+ nil
435
+ end
436
+
437
+ private
438
+
439
+ def _constable_time_machine
440
+ @_constable_time_machine ||= TimeMachine.build
441
+ end
442
+
443
+ def _constable_monotonic
444
+ ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
445
+ end
446
+
447
+ def _constable_assertion!
448
+ @assertion_count = assertion_count + 1
449
+ end
450
+
451
+ def _constable_fail(message, context: nil)
452
+ raise ::Constable::AssertionFailed.new(message, context: context)
453
+ end
454
+
455
+ def _constable_show(value)
456
+ _constable_inspect(value, limit: 120)
457
+ end
458
+
459
+ def _constable_inspect(value, limit: 120)
460
+ text = begin
461
+ value.inspect
462
+ rescue StandardError
463
+ "#<#{value.class} (inspect raised)>"
464
+ end
465
+ text.length > limit ? "#{text[0, limit]}…" : text
466
+ end
467
+
468
+ # Only worth showing when the one-line message had to truncate or wrap.
469
+ def _constable_comparison_context(expected, actual)
470
+ pair = [expected, actual].map { |v| _constable_inspect(v, limit: 2_000) }
471
+ return nil unless pair.any? { |text| text.length > 60 || text.include?("\n") }
472
+
473
+ "expected: #{pair[0]}\n actual: #{pair[1]}"
474
+ end
475
+
476
+ def _constable_evaluate(expression, block)
477
+ case expression
478
+ when ::String, ::Symbol then eval(expression.to_s, block.binding) # rubocop:disable Security/Eval
479
+ else
480
+ unless expression.respond_to?(:call)
481
+ raise ::Constable::Error,
482
+ "assert_difference needs a String or something callable, got #{expression.class}"
483
+ end
484
+
485
+ expression.call
486
+ end
487
+ end
488
+
489
+ def _constable_describe(expression)
490
+ expression.is_a?(::String) || expression.is_a?(::Symbol) ? expression.to_s.inspect : "the block's value"
491
+ end
492
+
493
+ # --------------------------------------------------------------------------------
494
+ # Time machinery
495
+ # --------------------------------------------------------------------------------
496
+
497
+ # A single instant, pre-computed in every shape the stubs hand back.
498
+ class Point
499
+ attr_reader :time, :date, :datetime
500
+
501
+ def initialize(time)
502
+ @time = time
503
+ @date = ::Date.new(time.year, time.month, time.day)
504
+ @datetime = ::DateTime.new(time.year, time.month, time.day, time.hour, time.min, time.sec,
505
+ Rational(time.utc_offset, 86_400))
506
+ end
507
+ end
508
+
509
+ # Two strategies, one interface. In a Rails app ActiveSupport already owns this job
510
+ # and does it well -- we borrow it rather than fight it for the same singleton
511
+ # methods. Without ActiveSupport we do it ourselves, since a :unit tier run has no
512
+ # Rails to lean on.
513
+ class TimeMachine
514
+ def self.build
515
+ DSL.activesupport_time_helpers? ? ActiveSupportMachine.new : StandaloneMachine.new
516
+ end
517
+
518
+ def frozen? = false
519
+
520
+ # Whole seconds only, matching ActiveSupport: sub-second precision survives a Ruby
521
+ # round trip but not a MySQL one, and a test should not care which it hit.
522
+ def coerce(value)
523
+ time = case value
524
+ when ::Time then value
525
+ when ::String then ::Time.parse(value)
526
+ when ::Numeric then ::Time.at(value)
527
+ else
528
+ unless value.respond_to?(:to_time)
529
+ raise ::Constable::Error, "Cannot travel to #{value.inspect} (#{value.class}) -- " \
530
+ "give me a Time, Date, String or epoch seconds"
531
+ end
532
+
533
+ value.to_time
534
+ end
535
+ ::Time.at(time.getlocal.to_i)
536
+ end
537
+ end
538
+
539
+ # Delegates to ActiveSupport::Testing::TimeHelpers through a private carrier object,
540
+ # so its `travel_to`/`freeze_time` never collide with ours in the Case's ancestry.
541
+ class ActiveSupportMachine < TimeMachine
542
+ def initialize
543
+ super
544
+ @depth = 0
545
+ end
546
+
547
+ def frozen? = @depth.positive?
548
+
549
+ def travel_to(time, &block)
550
+ @depth += 1
551
+ if block
552
+ begin
553
+ helper.travel_to(time, &block)
554
+ ensure
555
+ @depth -= 1
556
+ end
557
+ else
558
+ helper.travel_to(time)
559
+ end
560
+ end
561
+
562
+ def travel_back
563
+ return unless @depth.positive?
564
+
565
+ helper.travel_back
566
+ @depth = 0
567
+ end
568
+
569
+ private
570
+
571
+ def helper
572
+ @helper ||= ::Object.new.extend(::ActiveSupport::Testing::TimeHelpers)
573
+ end
574
+ end
575
+
576
+ # The Rails-free implementation: swap the singleton methods, remember exactly what
577
+ # was there before, and put it all back on the way out.
578
+ class StandaloneMachine < TimeMachine
579
+ def initialize
580
+ super
581
+ @point = nil
582
+ @installed = false
583
+ @originals = []
584
+ end
585
+
586
+ attr_reader :point
587
+
588
+ def frozen? = @installed
589
+
590
+ def travel_to(time, &block)
591
+ previous = @point
592
+ @point = Point.new(coerce(time))
593
+ install unless @installed
594
+ return @point.time unless block
595
+
596
+ begin
597
+ block.call
598
+ ensure
599
+ if previous
600
+ @point = previous
601
+ else
602
+ travel_back
603
+ end
604
+ end
605
+ end
606
+
607
+ def travel_back
608
+ return unless @installed
609
+
610
+ @originals.reverse_each do |target, name, original, owned|
611
+ singleton = target.singleton_class
612
+ singleton.send(:remove_method, name) if singleton.method_defined?(name, false)
613
+ singleton.send(:define_method, name, original) if owned && original
614
+ end
615
+ @originals.clear
616
+ @installed = false
617
+ @point = nil
618
+ end
619
+
620
+ private
621
+
622
+ def install
623
+ machine = self
624
+
625
+ stub(::Time, :now) { machine.point.time }
626
+ stub(::Date, :today) { machine.point.date }
627
+ stub(::DateTime, :now) { machine.point.datetime }
628
+
629
+ # Only present when ActiveSupport's core extensions are loaded; honour the
630
+ # application time zone if one is set, since that is what Time.current means.
631
+ if ::Time.respond_to?(:current)
632
+ stub(::Time, :current) do
633
+ zone = ::Time.respond_to?(:zone) ? ::Time.zone : nil
634
+ zone ? zone.at(machine.point.time) : machine.point.time
635
+ end
636
+ end
637
+ stub(::Date, :current) { machine.point.date } if ::Date.respond_to?(:current)
638
+
639
+ @installed = true
640
+ end
641
+
642
+ def stub(target, name, &)
643
+ singleton = target.singleton_class
644
+ original = singleton.instance_method(name)
645
+ owned = singleton.method_defined?(name, false)
646
+ @originals << [target, name, original, owned]
647
+ target.define_singleton_method(name, &)
648
+ end
649
+ end
650
+
651
+ # --------------------------------------------------------------------------------
652
+ # Network guard
653
+ # --------------------------------------------------------------------------------
654
+
655
+ # Blocks real outbound HTTP for the duration of a test.
656
+ #
657
+ # A prepended module cannot be un-prepended in Ruby, so the patch goes on once and
658
+ # stays; what the teardown flips is whether it bites. When WebMock (or anything
659
+ # WebMock-shaped) is loaded it already owns Net::HTTP, and two libraries wrestling
660
+ # over the same method is how mysterious test failures are born -- so we hand it the
661
+ # job and take it back at teardown.
662
+ module NetworkGuard
663
+ class << self
664
+ def enabled?
665
+ @enabled ||= false
666
+ end
667
+
668
+ def enable!
669
+ return true if enabled?
670
+
671
+ if webmock?
672
+ @webmock_engaged = true
673
+ ::WebMock.enable! if ::WebMock.respond_to?(:enable!)
674
+ ::WebMock.disable_net_connect!(allow_localhost: false)
675
+ else
676
+ install!
677
+ end
678
+
679
+ @enabled = true
680
+ end
681
+
682
+ def disable!
683
+ return false unless enabled?
684
+
685
+ if @webmock_engaged
686
+ ::WebMock.allow_net_connect! if defined?(::WebMock) && ::WebMock.respond_to?(:allow_net_connect!)
687
+ @webmock_engaged = false
688
+ end
689
+
690
+ @enabled = false
691
+ true
692
+ end
693
+
694
+ # The guard steps aside inside an `unsafe` block -- that is what unsafe is for,
695
+ # and the warning has already been filed.
696
+ def blocking?
697
+ enabled? && !DSL.unsafe?
698
+ end
699
+
700
+ def intercept!(address, port = nil)
701
+ host = port ? "#{address}:#{port}" : address.to_s
702
+ raise ::Constable::Error, <<~MSG.strip
703
+ Real HTTP connection attempted to #{host} while stub_network! is in force.
704
+
705
+ A test that talks to the network is a test that fails when someone else's server
706
+ is slow. Stub this request instead -- a WebMock/VCR stub, or a double on the
707
+ client object -- so the response is yours to control.
708
+
709
+ If this test genuinely must reach #{address}, say so out loud:
710
+
711
+ unsafe { ... } # why this test really does need the network
712
+ MSG
713
+ end
714
+
715
+ private
716
+
717
+ def webmock?
718
+ defined?(::WebMock) && ::WebMock.respond_to?(:disable_net_connect!)
719
+ end
720
+
721
+ def install!
722
+ return true if @installed
723
+
724
+ require "net/http"
725
+ ::Net::HTTP.prepend(NetHTTPGuard)
726
+ @installed = true
727
+ end
728
+ end
729
+ end
730
+
731
+ # Net::HTTP#start covers nearly everything (Net::HTTP.get, .get_response and a bare
732
+ # #request on an unstarted connection all route through it); #request is hooked too
733
+ # so an already-open connection cannot slip past.
734
+ module NetHTTPGuard
735
+ def start(*args, **kwargs, &)
736
+ NetworkGuard.intercept!(address, port) if NetworkGuard.blocking?
737
+ super
738
+ end
739
+
740
+ def request(*args, &)
741
+ NetworkGuard.intercept!(address, port) if NetworkGuard.blocking?
742
+ super
743
+ end
744
+ end
745
+
746
+ # --------------------------------------------------------------------------------
747
+ # Call sites
748
+ # --------------------------------------------------------------------------------
749
+
750
+ # Where an `unsafe` block was written, and what the author said about it.
751
+ #
752
+ # The reporter prints the reason verbatim, so an unexplained escape hatch reads as
753
+ # exactly that in the summary -- which is the pressure that gets it removed.
754
+ SourceSite = Struct.new(:path, :line, :snippet, :comment) do
755
+ CACHE = {} # rubocop:disable Lint/ConstantDefinitionInBlock, Style/MutableConstant
756
+
757
+ def self.at(location)
758
+ return new(nil, nil, "unsafe block", nil) unless location
759
+
760
+ path = location.absolute_path || location.path
761
+ line = location.lineno
762
+ text = source_line(path, line)
763
+ return new(path, line, "unsafe block", nil) unless text
764
+
765
+ snippet, comment = split(text)
766
+ comment ||= preceding_comment(path, line)
767
+ new(path, line, snippet, comment)
768
+ end
769
+
770
+ # The trailing comment on the call's own line, else a whole-line comment directly
771
+ # above it -- "adjacent", the same rule the UnsafeBlockVisibility cop enforces.
772
+ def self.split(text)
773
+ tokens = begin
774
+ ::Ripper.lex(text)
775
+ rescue StandardError
776
+ []
777
+ end
778
+ found = tokens.find { |(_pos, type, _tok)| type == :on_comment }
779
+ return [text.strip, nil] unless found
780
+
781
+ column = found[0][1]
782
+ [text[0...column].strip, found[2].to_s.sub(/\A#+\s*/, "").strip]
783
+ end
784
+
785
+ def self.preceding_comment(path, line)
786
+ text = source_line(path, line - 1).to_s.strip
787
+ return nil unless text.start_with?("#")
788
+
789
+ stripped = text.sub(/\A#+\s*/, "").strip
790
+ stripped.empty? ? nil : stripped
791
+ end
792
+
793
+ def self.source_line(path, line)
794
+ return nil if path.nil? || line.nil? || line < 1
795
+
796
+ lines = CACHE[path] ||= (File.readlines(path) if File.file?(path)) || []
797
+ lines[line - 1]&.chomp
798
+ rescue StandardError
799
+ nil
800
+ end
801
+
802
+ def reason
803
+ comment
804
+ end
805
+
806
+ def location
807
+ return nil unless path
808
+
809
+ root = ::Constable.root.to_s
810
+ shown = path.to_s
811
+ shown = shown.delete_prefix("#{root}/") if root != "" && shown.start_with?("#{root}/")
812
+ "#{shown}:#{line}"
813
+ end
814
+
815
+ # Reads back in the summary as, e.g.:
816
+ # unsafe { sleep(0.1) } — "testing an actual timeout path, not a code smell"
817
+ def warning_message(reason)
818
+ code = snippet.to_s.empty? ? "unsafe block" : truncate(snippet)
819
+ if reason.to_s.strip.empty?
820
+ "#{code} — no reason given; add a trailing comment or unsafe(\"why\")"
821
+ else
822
+ "#{code} — \"#{reason.to_s.strip}\""
823
+ end
824
+ end
825
+
826
+ private
827
+
828
+ def truncate(text, limit = 100)
829
+ text.length > limit ? "#{text[0, limit]}…" : text
830
+ end
831
+ end
832
+ end
833
+ end