constable-rails 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +88 -0
- data/LICENSE.txt +21 -0
- data/README.md +515 -0
- data/exe/constable +7 -0
- data/lib/constable/case.rb +336 -0
- data/lib/constable/cli.rb +475 -0
- data/lib/constable/cold_case/minitest.rb +342 -0
- data/lib/constable/cold_case/rspec.rb +334 -0
- data/lib/constable/cold_case.rb +280 -0
- data/lib/constable/config.rb +125 -0
- data/lib/constable/coverage.rb +951 -0
- data/lib/constable/diff.rb +212 -0
- data/lib/constable/dsl.rb +833 -0
- data/lib/constable/identity.rb +121 -0
- data/lib/constable/importer/modernizer.rb +860 -0
- data/lib/constable/importer/reopener.rb +468 -0
- data/lib/constable/importer.rb +51 -0
- data/lib/constable/investigation.rb +67 -0
- data/lib/constable/isolation.rb +171 -0
- data/lib/constable/jail.rb +399 -0
- data/lib/constable/log_router.rb +197 -0
- data/lib/constable/matchers.rb +834 -0
- data/lib/constable/order_audit.rb +130 -0
- data/lib/constable/rails_support.rb +213 -0
- data/lib/constable/railtie.rb +36 -0
- data/lib/constable/registry.rb +57 -0
- data/lib/constable/reporter.rb +625 -0
- data/lib/constable/result.rb +149 -0
- data/lib/constable/runner.rb +697 -0
- data/lib/constable/selection.rb +205 -0
- data/lib/constable/storage/adapter.rb +91 -0
- data/lib/constable/storage/mysql_adapter.rb +125 -0
- data/lib/constable/storage/postgres_adapter.rb +125 -0
- data/lib/constable/storage/sqlite_adapter.rb +84 -0
- data/lib/constable/storage.rb +847 -0
- data/lib/constable/version.rb +5 -0
- data/lib/constable/warrants.rb +290 -0
- data/lib/constable-rails.rb +16 -0
- data/lib/constable.rb +151 -0
- data/lib/generators/constable/base.rb +99 -0
- data/lib/generators/constable/channel/channel_generator.rb +20 -0
- data/lib/generators/constable/channel/templates/channel_case.rb.tt +29 -0
- data/lib/generators/constable/controller/controller_generator.rb +25 -0
- data/lib/generators/constable/controller/templates/controller_case.rb.tt +32 -0
- data/lib/generators/constable/generator/generator_generator.rb +31 -0
- data/lib/generators/constable/generator/templates/generator_case.rb.tt +28 -0
- data/lib/generators/constable/helper/helper_generator.rb +23 -0
- data/lib/generators/constable/helper/templates/helper_case.rb.tt +19 -0
- data/lib/generators/constable/import_generator.rb +137 -0
- data/lib/generators/constable/install_generator.rb +188 -0
- data/lib/generators/constable/integration/integration_generator.rb +27 -0
- data/lib/generators/constable/integration/templates/request_case.rb.tt +22 -0
- data/lib/generators/constable/job/job_generator.rb +20 -0
- data/lib/generators/constable/job/templates/job_case.rb.tt +33 -0
- data/lib/generators/constable/mailbox/mailbox_generator.rb +20 -0
- data/lib/generators/constable/mailbox/templates/mailbox_case.rb.tt +26 -0
- data/lib/generators/constable/mailer/mailer_generator.rb +32 -0
- data/lib/generators/constable/mailer/templates/mailer_case.rb.tt +34 -0
- data/lib/generators/constable/mailer/templates/preview.rb.tt +14 -0
- data/lib/generators/constable/model/model_generator.rb +31 -0
- data/lib/generators/constable/model/templates/model_case.rb.tt +37 -0
- data/lib/generators/constable/resource/resource_generator.rb +27 -0
- data/lib/generators/constable/scaffold/scaffold_generator.rb +42 -0
- data/lib/generators/constable/scaffold/templates/api_controller_case.rb.tt +54 -0
- data/lib/generators/constable/scaffold/templates/controller_case.rb.tt +70 -0
- data/lib/generators/constable/scaffold/templates/system_case.rb.tt +53 -0
- data/lib/generators/constable/system/system_generator.rb +20 -0
- data/lib/generators/constable/system/templates/system_case.rb.tt +18 -0
- data/lib/generators/constable/templates/authenticatable.rb.tt +31 -0
- data/lib/generators/constable/templates/case_helper.rb.tt +179 -0
- data/lib/generators/constable/templates/config.yml.tt +67 -0
- data/lib/generators/constable/templates/example_case.rb.tt +56 -0
- data/lib/generators/constable/templates/matchers.rb.tt +36 -0
- data/lib/generators/constable/templates/rubocop.yml.tt +12 -0
- metadata +209 -0
|
@@ -0,0 +1,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
|