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,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