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,336 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Constable
|
|
4
|
+
# The base class for a case file -- one file, roughly one subject under test.
|
|
5
|
+
#
|
|
6
|
+
# class UsersController::CreatesUserCase < IntegrationCase
|
|
7
|
+
# witness(:valid_params) { { user: { email: "a@b.com" } } }
|
|
8
|
+
# briefing { stub_network! }
|
|
9
|
+
#
|
|
10
|
+
# investigate "creates a user with valid params" do
|
|
11
|
+
# post users_path, params: valid_params
|
|
12
|
+
# attest(response).to be_created
|
|
13
|
+
# end
|
|
14
|
+
# end
|
|
15
|
+
#
|
|
16
|
+
# `investigate` is a registration DSL, not a method definition. Every investigation is
|
|
17
|
+
# run in its own fresh instance of the owning class, which is the whole reason there is
|
|
18
|
+
# no `before(:all)` equivalent here and never will be: class-level shared state is the
|
|
19
|
+
# thing this framework exists to make impossible.
|
|
20
|
+
class Case
|
|
21
|
+
# The runtime DSL (freeze_time, stub_network!, unsafe, assertion primitives) and the
|
|
22
|
+
# `attest` expectation sugar live in their own components, mixed in here so every
|
|
23
|
+
# investigation body has both without asking.
|
|
24
|
+
include Constable::DSL
|
|
25
|
+
include Constable::Matchers::Expectations
|
|
26
|
+
|
|
27
|
+
class << self
|
|
28
|
+
# Every subclass -- a tier base class, a real case, a docket -- starts with its own
|
|
29
|
+
# empty ledger. Only real cases go on the registry's top-level list; a docket is
|
|
30
|
+
# reachable through the class that opened it.
|
|
31
|
+
def inherited(subclass)
|
|
32
|
+
super
|
|
33
|
+
subclass.instance_variable_set(:@constable_docket, @constable_defining_docket == true)
|
|
34
|
+
Constable.registry.register(subclass)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Registers a test. The description is a plain string, so punctuation and
|
|
38
|
+
# interpolation are fine -- nothing here is translated into a method name.
|
|
39
|
+
def investigate(description, &block)
|
|
40
|
+
raise ArgumentError, "investigate(#{description.inspect}) requires a block" unless block
|
|
41
|
+
|
|
42
|
+
file, line = block.source_location
|
|
43
|
+
investigation = Investigation.new(
|
|
44
|
+
case_class: self,
|
|
45
|
+
description: description.to_s,
|
|
46
|
+
block: block,
|
|
47
|
+
file: file,
|
|
48
|
+
line: line,
|
|
49
|
+
docket_path: docket_path.dup,
|
|
50
|
+
tier: tier_for(file)
|
|
51
|
+
)
|
|
52
|
+
constable_children << investigation
|
|
53
|
+
investigation
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# A fixture/helper, memoized **per-test**. The memo lives on the fresh instance the
|
|
57
|
+
# investigation runs in, never on the class and never on the process -- per-process
|
|
58
|
+
# caching would leak state between tests, which defeats the entire point.
|
|
59
|
+
def witness(name, &block)
|
|
60
|
+
raise ArgumentError, "witness(#{name.inspect}) requires a block" unless block
|
|
61
|
+
|
|
62
|
+
name = name.to_sym
|
|
63
|
+
own_witnesses[name] = block
|
|
64
|
+
define_method(name) do
|
|
65
|
+
constable_witnesses.fetch(name) { constable_witnesses[name] = instance_exec(&block) }
|
|
66
|
+
end
|
|
67
|
+
name
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Setup, run before every investigation in this case. Multiple are allowed and a
|
|
71
|
+
# parent's briefings always run before a child's.
|
|
72
|
+
def briefing(&block)
|
|
73
|
+
raise ArgumentError, "briefing requires a block" unless block
|
|
74
|
+
|
|
75
|
+
own_briefings << block
|
|
76
|
+
block
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# --- Minitest lifecycle compatibility -----------------------------------
|
|
80
|
+
#
|
|
81
|
+
# `briefing` is how a person writes setup in Constable. `setup` exists because
|
|
82
|
+
# Rails' testing modules -- ActionDispatch::IntegrationTest::Behavior and friends --
|
|
83
|
+
# are written against Minitest's contract and call these macros on the class they
|
|
84
|
+
# are included into. Answering that contract is what lets a case get `post
|
|
85
|
+
# users_path` and `response` for free instead of a reimplementation of them.
|
|
86
|
+
#
|
|
87
|
+
# It is a synonym, not a second mechanism: a `setup` block is appended to exactly
|
|
88
|
+
# the same list `briefing` appends to, so ordering is one rule rather than two --
|
|
89
|
+
# parents before children, declaration order preserved within a class.
|
|
90
|
+
|
|
91
|
+
# setup { ... } and setup :method_name, :other_method are both legal, because both
|
|
92
|
+
# forms appear in Rails' own modules.
|
|
93
|
+
def setup(*method_names, &block)
|
|
94
|
+
method_names.each { |name| own_briefings << proc { send(name) } }
|
|
95
|
+
own_briefings << block if block
|
|
96
|
+
self
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Cleanup, run after the investigation body in reverse declaration order -- a
|
|
100
|
+
# child's teardowns before its parent's -- and run whether or not the body raised.
|
|
101
|
+
def teardown(*method_names, &block)
|
|
102
|
+
method_names.each { |name| own_teardowns << proc { send(name) } }
|
|
103
|
+
own_teardowns << block if block
|
|
104
|
+
self
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Innermost-first: the mirror image of #briefings.
|
|
108
|
+
def teardowns
|
|
109
|
+
constable_lineage.flat_map(&:own_teardowns).reverse
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def own_teardowns = (@constable_own_teardowns ||= [])
|
|
113
|
+
|
|
114
|
+
# In-file grouping. A docket is an anonymous subclass with the description pushed
|
|
115
|
+
# onto its docket path -- so witnesses and briefings declared inside it are scoped
|
|
116
|
+
# to it, and nothing is shared with its siblings. Nests arbitrarily deep.
|
|
117
|
+
def docket(description, &block)
|
|
118
|
+
raise ArgumentError, "docket(#{description.inspect}) requires a block" unless block
|
|
119
|
+
|
|
120
|
+
previous = @constable_defining_docket
|
|
121
|
+
@constable_defining_docket = true
|
|
122
|
+
subclass = Class.new(self)
|
|
123
|
+
@constable_defining_docket = previous
|
|
124
|
+
|
|
125
|
+
subclass.instance_variable_set(:@constable_docket_path, docket_path + [description.to_s])
|
|
126
|
+
subclass.instance_variable_set(:@constable_docket_description, description.to_s)
|
|
127
|
+
constable_children << subclass
|
|
128
|
+
subclass.class_eval(&block)
|
|
129
|
+
subclass
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# :unit / :integration / :system. Reads as an inherited value, so a tier base class
|
|
133
|
+
# (`class UnitCase < Constable::Case; tier :unit; end`) hands its tier to every case
|
|
134
|
+
# that subclasses it.
|
|
135
|
+
def tier(value = nil)
|
|
136
|
+
return self.tier = value unless value.nil?
|
|
137
|
+
|
|
138
|
+
return @constable_tier if defined?(@constable_tier) && @constable_tier
|
|
139
|
+
|
|
140
|
+
superclass.respond_to?(:tier) ? superclass.tier : nil
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def tier=(value)
|
|
144
|
+
@constable_tier = value&.to_sym
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Every investigation belonging to this class and to its dockets, flattened, in
|
|
148
|
+
# declaration order.
|
|
149
|
+
def investigations
|
|
150
|
+
constable_children.flat_map do |child|
|
|
151
|
+
child.is_a?(Investigation) ? [child] : child.investigations
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def own_investigations = constable_children.grep(Investigation)
|
|
156
|
+
def dockets = constable_children.grep(Class)
|
|
157
|
+
|
|
158
|
+
# Briefings run outermost-first: Constable::Case, then the tier base class, then the
|
|
159
|
+
# case, then each docket in turn.
|
|
160
|
+
def briefings
|
|
161
|
+
constable_lineage.flat_map(&:own_briefings)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def witnesses
|
|
165
|
+
constable_lineage.each_with_object({}) { |klass, out| out.merge!(klass.own_witnesses) }
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def witness_names = witnesses.keys
|
|
169
|
+
|
|
170
|
+
def own_briefings = (@constable_own_briefings ||= [])
|
|
171
|
+
def own_witnesses = (@constable_own_witnesses ||= {})
|
|
172
|
+
|
|
173
|
+
# ["as an admin", "with a locked account"] -- the enclosing docket descriptions.
|
|
174
|
+
def docket_path
|
|
175
|
+
@constable_docket_path ||= (superclass.respond_to?(:docket_path) ? superclass.docket_path.dup : [])
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def docket_description
|
|
179
|
+
defined?(@constable_docket_description) ? @constable_docket_description : nil
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# True for the anonymous subclasses `docket` creates. They're deliberately kept off
|
|
183
|
+
# the registry's top-level list.
|
|
184
|
+
def docket?
|
|
185
|
+
defined?(@constable_docket) && @constable_docket == true
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# The nearest named, non-docket ancestor -- so an anonymous docket still reports as
|
|
189
|
+
# "UsersController::CreatesUserCase".
|
|
190
|
+
def constable_display_name
|
|
191
|
+
klass = self
|
|
192
|
+
while klass && klass != Constable::Case
|
|
193
|
+
return klass.name if !klass.docket? && klass.name
|
|
194
|
+
|
|
195
|
+
klass = klass.superclass
|
|
196
|
+
end
|
|
197
|
+
"AnonymousCase"
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Runner entry point. Builds the fresh instance, runs every inherited briefing in
|
|
201
|
+
# order, and executes the investigation body in that same instance.
|
|
202
|
+
#
|
|
203
|
+
# The instance is always of `investigation.case_class` -- a docket's investigation
|
|
204
|
+
# belongs to the docket subclass, and running it anywhere else would quietly skip
|
|
205
|
+
# that docket's witnesses and briefings. So `Constable::Case.run(inv)` is enough;
|
|
206
|
+
# the receiver doesn't have to be the right class.
|
|
207
|
+
def run(investigation, instance: nil)
|
|
208
|
+
(instance || constable_instance_for(investigation)).run_investigation(investigation)
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# A fresh instance, bound to nothing but this investigation. One per investigation,
|
|
212
|
+
# always.
|
|
213
|
+
def constable_instance_for(investigation)
|
|
214
|
+
investigation.case_class.new.tap { |instance| instance.constable_investigation = investigation }
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
private
|
|
218
|
+
|
|
219
|
+
# Self last: the outermost class briefs first.
|
|
220
|
+
def constable_lineage
|
|
221
|
+
chain = []
|
|
222
|
+
klass = self
|
|
223
|
+
while klass && klass <= Constable::Case
|
|
224
|
+
chain.unshift(klass)
|
|
225
|
+
klass = klass.superclass
|
|
226
|
+
end
|
|
227
|
+
chain
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def constable_children = (@constable_children ||= [])
|
|
231
|
+
|
|
232
|
+
# An explicit `tier` macro always wins; path convention is only the fallback for a
|
|
233
|
+
# case that never declared one.
|
|
234
|
+
def tier_for(file)
|
|
235
|
+
tier || (file && Constable.config.tier_for(file))
|
|
236
|
+
rescue StandardError
|
|
237
|
+
tier
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# The investigation currently being run in this instance. The runtime DSL uses it to
|
|
242
|
+
# point warnings and failures at the user's own file:line.
|
|
243
|
+
attr_accessor :constable_investigation
|
|
244
|
+
|
|
245
|
+
# Whatever the investigation raised, readable while teardown is running and nil when
|
|
246
|
+
# it passed. Rails' system-test screenshot helper asks a test whether it failed; this
|
|
247
|
+
# is how a case can answer without Minitest's result object.
|
|
248
|
+
attr_reader :constable_failure
|
|
249
|
+
|
|
250
|
+
# The full path the Runner takes for one test: fresh instance, before_setup,
|
|
251
|
+
# briefings, body, teardowns, after_teardown.
|
|
252
|
+
def run_investigation(investigation)
|
|
253
|
+
failure = nil
|
|
254
|
+
value = nil
|
|
255
|
+
begin
|
|
256
|
+
run_setup(investigation)
|
|
257
|
+
value = run_body(investigation)
|
|
258
|
+
rescue StandardError => e
|
|
259
|
+
failure = e
|
|
260
|
+
ensure
|
|
261
|
+
@constable_failure = failure
|
|
262
|
+
# A raise from teardown must never replace the investigation's own failure. The
|
|
263
|
+
# first thing that went wrong is the thing worth reporting; the rest is fallout.
|
|
264
|
+
teardown_failure = run_teardown
|
|
265
|
+
failure ||= teardown_failure
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
raise failure if failure
|
|
269
|
+
|
|
270
|
+
value
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# Setup only. A jailed test still gets this -- its briefings and witnesses run, just
|
|
274
|
+
# not its body -- so setup rot surfaces immediately instead of at the next jail run.
|
|
275
|
+
def run_setup(investigation = nil)
|
|
276
|
+
@constable_investigation = investigation if investigation
|
|
277
|
+
clear_witnesses!
|
|
278
|
+
before_setup
|
|
279
|
+
self.class.briefings.each { |briefing| instance_exec(&briefing) }
|
|
280
|
+
after_setup
|
|
281
|
+
self
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def run_body(investigation)
|
|
285
|
+
@constable_investigation = investigation
|
|
286
|
+
instance_exec(&investigation.block)
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# Returns the first exception raised rather than raising it, so the caller stays in
|
|
290
|
+
# charge of which failure the run reports. Every teardown runs even if an earlier one
|
|
291
|
+
# blew up -- half-released state is worse than a noisy log.
|
|
292
|
+
def run_teardown
|
|
293
|
+
errors = []
|
|
294
|
+
constable_swallow(errors) { before_teardown }
|
|
295
|
+
self.class.teardowns.each { |block| constable_swallow(errors) { instance_exec(&block) } }
|
|
296
|
+
constable_swallow(errors) { after_teardown }
|
|
297
|
+
errors.first
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
# The four hooks Minitest's contract requires. They are no-ops here on purpose: this
|
|
301
|
+
# is the bottom of the chain, and the Rails modules a tier base class mixes in sit
|
|
302
|
+
# above it, each calling super until it lands here.
|
|
303
|
+
def before_setup = (super if defined?(super))
|
|
304
|
+
def after_setup = (super if defined?(super))
|
|
305
|
+
def before_teardown = (super if defined?(super))
|
|
306
|
+
def after_teardown = (super if defined?(super))
|
|
307
|
+
|
|
308
|
+
# Minitest names a test by the method that defines it. Constable's descriptions are
|
|
309
|
+
# plain strings, so this is the closest honest answer -- and it is what Rails uses to
|
|
310
|
+
# name a failure screenshot, which is the only place it shows up.
|
|
311
|
+
def method_name
|
|
312
|
+
slug = constable_investigation&.full_description.to_s.gsub(/[^A-Za-z0-9]+/, "_")
|
|
313
|
+
slug = slug.gsub(/\A_+|_+\z/, "").downcase
|
|
314
|
+
slug.empty? ? "investigation" : slug[0, 120]
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
# Per-test memo store for `witness`. Fresh instance, fresh hash, no exceptions.
|
|
318
|
+
def constable_witnesses
|
|
319
|
+
@constable_witnesses ||= {}
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def clear_witnesses!
|
|
323
|
+
@constable_witnesses = {}
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def constable_display_name = self.class.constable_display_name
|
|
327
|
+
|
|
328
|
+
private
|
|
329
|
+
|
|
330
|
+
def constable_swallow(errors)
|
|
331
|
+
yield
|
|
332
|
+
rescue StandardError => e
|
|
333
|
+
errors << e
|
|
334
|
+
end
|
|
335
|
+
end
|
|
336
|
+
end
|