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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Constable
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,290 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Constable
4
+ # Warrants -- automatic flaky detection.
5
+ #
6
+ # Jail asks "does this block the build?". A warrant asks the question underneath it:
7
+ # *is this failure even real?* The two are deliberately separate mechanisms, and a test
8
+ # can be subject to both.
9
+ #
10
+ # Opt in with `warrants: true` in config, or `constable test --warrants` for one run.
11
+ # Once a warrant exists it stands on its own: a warranted test gets the full retry
12
+ # treatment on **every** future run whether or not the flag is passed, because the
13
+ # blotter entry carries the rule, not the command line.
14
+ #
15
+ # Issuing one:
16
+ #
17
+ # A test fails its normal attempt while warrants are active. Constable reruns that one
18
+ # test, in isolation, `warrant_retries` more times (default 5).
19
+ # * fails every retry -> a genuine failure. No warrant. Handled like any other
20
+ # failure, jail included if `--jail` is also on.
21
+ # * passes at least once -> flaky, not broken. A warrant goes on the blotter, the
22
+ # result stops blocking the build, and it gets its own section in the summary.
23
+ #
24
+ # Living under one:
25
+ #
26
+ # * all retries pass -> warrant cleared, entry removed, reported as cleared.
27
+ # * any retry fails -> warrant stands, "still under warrant", still non-blocking.
28
+ #
29
+ # The blotter is the only record. Nothing here ever writes to a source file, and there
30
+ # is deliberately no companion command that annotates specs -- same reasoning as jail:
31
+ # one source of truth, nothing to drift out of sync. `constable warrants release
32
+ # PATH:LINE` is the manual clear.
33
+ class Warrants
34
+ # What a run's adjudication concluded about one test.
35
+ #
36
+ # :issued a fresh warrant -- failed, then passed under retry
37
+ # :genuine no warrant -- failed every retry, this is a real failure
38
+ # :upheld standing warrant, still flaky
39
+ # :cleared standing warrant, clean sweep -- lifted
40
+ # :none the machinery did not apply
41
+ VERDICTS = %i[none issued genuine upheld cleared].freeze
42
+
43
+ # One row of the warrants table, normalized. Tolerant of string keys for the same
44
+ # reason Jail::Entry is: the storage layer is pluggable.
45
+ class Entry
46
+ attr_reader :identity, :label, :file, :line, :reason, :issued_at, :last_seen_at,
47
+ :times_seen, :clean_runs, :failed_runs, :row
48
+
49
+ def self.wrap(row)
50
+ return nil if row.nil?
51
+ return row if row.is_a?(Entry)
52
+ return nil unless row.respond_to?(:to_h)
53
+
54
+ hash = row.to_h
55
+ hash.empty? ? nil : new(hash)
56
+ end
57
+
58
+ def initialize(row)
59
+ @row = row.each_with_object({}) { |(k, v), out| out[k.to_sym] = v }
60
+ @identity = @row[:identity].to_s
61
+ @label = @row[:label]
62
+ @file = @row[:file].to_s
63
+ @line = @row[:line]&.to_i
64
+ @reason = @row[:reason]
65
+ @issued_at = @row[:issued_at]
66
+ @last_seen_at = @row[:last_seen_at]
67
+ @times_seen = (@row[:times_seen] || 0).to_i
68
+ @clean_runs = (@row[:clean_runs] || 0).to_i
69
+ @failed_runs = (@row[:failed_runs] || 0).to_i
70
+ end
71
+
72
+ # A cleared entry is handed back by storage as it was at the moment the row went
73
+ # away -- the only chance the reporter gets to name its final counters.
74
+ def cleared? = @row[:cleared] == true || @row[:state].to_s == "cleared"
75
+
76
+ def location = "#{@file}:#{@line}"
77
+ def to_h = @row.dup
78
+ end
79
+
80
+ attr_reader :config, :storage, :issued, :cleared, :upheld, :genuine, :verdicts
81
+
82
+ def initialize(config: Constable.config, storage: Constable.storage)
83
+ @config = config
84
+ @storage = storage
85
+ @issued = []
86
+ @cleared = []
87
+ @upheld = []
88
+ @genuine = []
89
+ @verdicts = {}
90
+ end
91
+
92
+ # How many isolated reruns one adjudication costs. Zero disables the mechanism
93
+ # outright -- there is nothing to learn from retrying a test no times.
94
+ def warrant_retries
95
+ retries = @config.respond_to?(:warrant_retries) ? @config.warrant_retries.to_i : 0
96
+ retries.negative? ? 0 : retries
97
+ end
98
+
99
+ # The flag, not the blotter.
100
+ def enabled? = @config.respond_to?(:warrants?) ? @config.warrants? : false
101
+
102
+ def active?(requested: false) = requested || enabled?
103
+
104
+ # The blotter, not the flag. A standing warrant outranks the flag in both directions:
105
+ # it applies without `--warrants`, and no flag is needed to clear it.
106
+ def standing?(identity) = !entry(identity).nil?
107
+ alias warranted? standing?
108
+
109
+ # "Is this identity subject to the warrant machinery this run?" -- either because
110
+ # somebody asked for warrants, or because this test already has one.
111
+ def applies_to?(identity, requested: false)
112
+ return false if warrant_retries.zero?
113
+
114
+ standing?(identity) || active?(requested: requested)
115
+ end
116
+
117
+ # "Should I actually spend retries on this result?" A standing warrant is retried
118
+ # however the normal attempt went; an ordinary test only earns retries by failing.
119
+ def retry?(result, requested: false)
120
+ return false if warrant_retries.zero?
121
+ return false if result.nil? || result.jailed? || result.skipped?
122
+ return true if standing?(result.identity)
123
+
124
+ active?(requested: requested) && result.failed?
125
+ end
126
+
127
+ # --- queries ---------------------------------------------------------------
128
+
129
+ def entries = Array(@storage.warrants).filter_map { |row| Entry.wrap(row) }
130
+ def entry(identity) = Entry.wrap(@storage.warrant_entry(identity.to_s))
131
+ def any? = !entries.empty?
132
+
133
+ # A warranted result never fails the build. That is the whole point of establishing
134
+ # that the failure was not real.
135
+ def blocks_build?(result) = result.failed?
136
+
137
+ # --- adjudication ----------------------------------------------------------
138
+
139
+ # The Runner's single entry point. Hands back the same Result, decided.
140
+ #
141
+ # warrants.adjudicate(result, requested: cli_flag, subject: investigation) do |subject, attempt|
142
+ # runner.run_one_in_isolation(subject) # => a Constable::Result
143
+ # end
144
+ #
145
+ # The block reruns exactly one test in isolation and returns its outcome -- a Result,
146
+ # a status Symbol, or a boolean. It is called `warrant_retries` times, always the full
147
+ # count: a partial sample is a worse answer than a slower one, and the retry statuses
148
+ # end up on `Result#retries` for the reporter.
149
+ #
150
+ # Order matters at the call site: adjudicate first, then hand the result to Jail. A
151
+ # warranted result is not a failure, so it must never reach the docket; a genuine one
152
+ # should, jail mode included.
153
+ def adjudicate(result, requested: false, subject: nil, &rerun)
154
+ decide(result, requested: requested, subject: subject, &rerun)
155
+ persist(result)
156
+ result
157
+ end
158
+
159
+ # The half that runs tests and decides, with no writes -- safe inside a fork worker,
160
+ # where the architecture's "only the parent writes" rule applies. The parent calls
161
+ # #persist on the Result that comes back over the pipe.
162
+ def decide(result, requested: false, subject: nil, &rerun)
163
+ return result unless retry?(result, requested: requested)
164
+ raise ArgumentError, "Warrants#decide needs a block that reruns one test in isolation" unless rerun
165
+
166
+ statuses = run_retries(result, subject, rerun)
167
+ result.retries = statuses
168
+ result.status = decided_status(result, statuses)
169
+ result
170
+ end
171
+
172
+ # The half that writes. Derives the verdict from the blotter plus the retry statuses
173
+ # already on the Result, so it works equally on a locally decided result and on one
174
+ # that arrived from a worker as a hash.
175
+ def persist(result, verdict = verdict_for(result))
176
+ @verdicts[result.identity] = verdict unless verdict == :none
177
+
178
+ case verdict
179
+ when :issued
180
+ @storage.issue_warrant(result.identity, label: result.display_label, file: result.file,
181
+ line: result.line, reason: issue_reason(result))
182
+ @issued << result
183
+ when :upheld
184
+ @storage.touch_warrant(result.identity, cleared: false)
185
+ @upheld << result
186
+ when :cleared
187
+ # touch_warrant(cleared: true) lifts the warrant and hands back the final row.
188
+ # clear_warrant stays the manual path; calling both would just be noise.
189
+ @storage.touch_warrant(result.identity, cleared: true)
190
+ @cleared << result
191
+ when :genuine
192
+ @genuine << result
193
+ end
194
+
195
+ verdict
196
+ end
197
+
198
+ # Fully derivable from (does a warrant stand?, how did the retries go?), which is why
199
+ # a worker never has to ship a verdict back alongside the result.
200
+ def verdict_for(result)
201
+ statuses = Array(result&.retries).map { |status| normalize_status(status) }
202
+ return :none if statuses.empty?
203
+
204
+ if standing?(result.identity)
205
+ statuses.all?(:passed) ? :cleared : :upheld
206
+ else
207
+ statuses.any?(:passed) ? :issued : :genuine
208
+ end
209
+ end
210
+
211
+ # --- human operations ------------------------------------------------------
212
+
213
+ # `constable warrants release PATH:LINE`.
214
+ def release(identity) = @storage.clear_warrant(identity.to_s) ? true : false
215
+
216
+ # Counts for the summary line and its own section.
217
+ def summary_counts
218
+ { issued: @issued.size, cleared: @cleared.size, upheld: @upheld.size, standing: entries.size }
219
+ end
220
+
221
+ # --- PATH:LINE resolution ---------------------------------------------------
222
+
223
+ # The CLI speaks in file:line, the blotter is keyed by content hash. Warrant rows
224
+ # carry both; loaded investigations are the fallback.
225
+ def resolve(target)
226
+ text = target.to_s.strip
227
+ return nil if text.empty?
228
+ return text if text.match?(/\A[0-9a-f]{8,64}\z/) && entry(text)
229
+
230
+ file, line = Jail.split_target(text)
231
+ return nil if file.empty?
232
+
233
+ matches = entries.select { |e| Jail.same_path?(e.file, file) }
234
+ matches = matches.select { |e| e.line == line } if line
235
+ return matches.first.identity if matches.any?
236
+
237
+ Jail.registry_identity(file, line)
238
+ end
239
+
240
+ def resolve!(target)
241
+ resolve(target) || raise(Constable::Error, "no test found for #{target.inspect} " \
242
+ "(expected PATH:LINE, e.g. test/cases/users_case.rb:12)")
243
+ end
244
+
245
+ private
246
+
247
+ def run_retries(result, subject, rerun)
248
+ Array.new(warrant_retries) do |index|
249
+ normalize_status(call_rerun(rerun, subject || result, index + 1))
250
+ end
251
+ end
252
+
253
+ # Blocks are lenient about extra arguments; lambdas are not, and somebody will pass one.
254
+ def call_rerun(rerun, subject, attempt)
255
+ return rerun.call(subject, attempt) unless rerun.lambda?
256
+
257
+ case rerun.arity
258
+ when 0 then rerun.call
259
+ when 1 then rerun.call(subject)
260
+ else rerun.call(subject, attempt)
261
+ end
262
+ end
263
+
264
+ def decided_status(result, statuses)
265
+ if standing?(result.identity)
266
+ # A standing warrant is judged on the retries alone -- a clean sweep lifts it,
267
+ # anything else keeps it standing and non-blocking.
268
+ statuses.all?(:passed) ? :passed : :warranted
269
+ elsif statuses.any?(:passed)
270
+ :warranted
271
+ else
272
+ result.status # a genuine failure keeps whatever kind of failure it was
273
+ end
274
+ end
275
+
276
+ def normalize_status(value)
277
+ return value.passed? ? :passed : :failed if value.respond_to?(:passed?)
278
+ return :passed if value == true
279
+ return :failed if value == false || value.nil?
280
+
281
+ value.to_s == "passed" ? :passed : :failed
282
+ end
283
+
284
+ def issue_reason(result)
285
+ statuses = Array(result.retries).map { |status| normalize_status(status) }
286
+ passes = statuses.count(:passed)
287
+ "failed its normal attempt, then passed #{passes} of #{statuses.size} isolated retries"
288
+ end
289
+ end
290
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The gem is published as "constable-rails" because the name "constable" was claimed on
4
+ # RubyGems in 2011 by an unrelated, long-abandoned gem.
5
+ #
6
+ # Bundler.require requires each gem by its *gem* name, so an app writing the ordinary
7
+ #
8
+ # gem "constable-rails"
9
+ #
10
+ # gets `require "constable-rails"` and nothing else. Without this file that raises
11
+ # LoadError, and Bundler's only fallback is "constable/rails", which does not exist
12
+ # either -- so the gem would never be loaded into the app at all. Everything would still
13
+ # appear to work, because the `constable` executable requires "constable" itself, right
14
+ # up until the moment something needed the Railtie: `rails generate scaffold` would
15
+ # quietly keep emitting Minitest files, for the framework the app had just replaced.
16
+ require "constable"
data/lib/constable.rb ADDED
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "constable/version"
4
+
5
+ # Constable -- an opinionated, strict Rails testing framework.
6
+ #
7
+ # Published on RubyGems as "constable-rails"; everything inside is simply Constable.
8
+ #
9
+ # Five ideas hold the whole thing up:
10
+ #
11
+ # 1. Isolation is non-negotiable in native code. No class-level shared state, no
12
+ # before(:all) equivalent. Every native test gets a clean transaction and a clean
13
+ # object graph.
14
+ # 2. Nondeterminism is caught by the linter, not discovered in CI.
15
+ # 3. Adoption never requires a rewrite. A whole existing RSpec/Minitest file runs
16
+ # untouched from day one.
17
+ # 4. Every escape hatch is visible. Nothing that bends the rules is ever silent.
18
+ # 5. Fast is the default, not an opt-in.
19
+ module Constable
20
+ class Error < StandardError; end
21
+ class ConfigurationError < Error; end
22
+
23
+ # Raised by assertion primitives. Distinct from Error so a genuine bug in a test never
24
+ # gets mistaken for an assertion failure.
25
+ class AssertionFailed < Error
26
+ attr_reader :context
27
+
28
+ def initialize(message, context: nil)
29
+ @context = context
30
+ super(message)
31
+ end
32
+ end
33
+
34
+ autoload :Backtrace, "constable/result"
35
+ autoload :CLI, "constable/cli"
36
+ autoload :Case, "constable/case"
37
+ autoload :ColdCase, "constable/cold_case"
38
+ autoload :Config, "constable/config"
39
+ autoload :Coverage, "constable/coverage"
40
+ autoload :DSL, "constable/dsl"
41
+ autoload :Diff, "constable/diff"
42
+ autoload :Failure, "constable/result"
43
+ autoload :Identity, "constable/identity"
44
+ autoload :Importer, "constable/importer"
45
+ autoload :Investigation, "constable/investigation"
46
+ autoload :Isolation, "constable/isolation"
47
+ autoload :Jail, "constable/jail"
48
+ autoload :LogRouter, "constable/log_router"
49
+ autoload :Matchers, "constable/matchers"
50
+ autoload :OrderAudit, "constable/order_audit"
51
+ autoload :RailsSupport, "constable/rails_support"
52
+ autoload :Registry, "constable/registry"
53
+ autoload :Reporter, "constable/reporter"
54
+ autoload :Result, "constable/result"
55
+ autoload :Runner, "constable/runner"
56
+ autoload :Selection, "constable/selection"
57
+ autoload :Storage, "constable/storage"
58
+ autoload :Warrants, "constable/warrants"
59
+
60
+ class << self
61
+ attr_writer :root, :config, :storage
62
+
63
+ # The application root. Rails.root when Rails is booted, otherwise the nearest
64
+ # directory that looks like a project (has .constable/, Gemfile, or .git).
65
+ def root
66
+ @root ||= detect_root
67
+ end
68
+
69
+ def config
70
+ @config ||= Config.load(root: root)
71
+ end
72
+
73
+ # Code-level configuration -- matchers, tier base classes, one-time global setup.
74
+ # Settings that are merely settings belong in .constable/config.yml instead.
75
+ #
76
+ # Constable.configure do |c|
77
+ # c.parallel_workers = 4
78
+ # c.before_suite { Capybara.default_driver = :rack_test }
79
+ # end
80
+ def configure
81
+ yield configuration if block_given?
82
+ configuration
83
+ end
84
+
85
+ def configuration
86
+ @configuration ||= Configuration.new
87
+ end
88
+
89
+ def storage
90
+ @storage ||= Storage::Adapter.build(config).tap(&:setup!)
91
+ end
92
+
93
+ def registry
94
+ @registry ||= Registry.new
95
+ end
96
+
97
+ # Warnings are never silent and never fatal by default. They accumulate through a run
98
+ # and always get their own section in the summary.
99
+ def warn!(message, location: nil, kind: :unsafe)
100
+ warnings << { message: message, location: location, kind: kind }
101
+ end
102
+
103
+ def warnings
104
+ @warnings ||= []
105
+ end
106
+
107
+ def reset!
108
+ @config = nil
109
+ @storage = nil
110
+ @registry = nil
111
+ @warnings = []
112
+ end
113
+
114
+ private
115
+
116
+ def detect_root
117
+ return Rails.root.to_s if defined?(Rails) && Rails.respond_to?(:root) && Rails.root
118
+
119
+ dir = Dir.pwd
120
+ until dir == "/"
121
+ return dir if File.exist?(File.join(dir, ".constable")) ||
122
+ File.exist?(File.join(dir, "Gemfile")) ||
123
+ File.directory?(File.join(dir, ".git"))
124
+
125
+ dir = File.dirname(dir)
126
+ end
127
+ Dir.pwd
128
+ end
129
+ end
130
+
131
+ # Code-level configuration set from test/case_helper.rb.
132
+ class Configuration
133
+ attr_accessor :parallel_workers, :seed, :coverage, :warrants
134
+
135
+ def initialize
136
+ @before_suite_hooks = []
137
+ @after_suite_hooks = []
138
+ end
139
+
140
+ def before_suite(&block) = @before_suite_hooks << block
141
+ def after_suite(&block) = @after_suite_hooks << block
142
+
143
+ def run_before_suite! = @before_suite_hooks.each(&:call)
144
+ def run_after_suite! = @after_suite_hooks.each(&:call)
145
+ end
146
+ end
147
+
148
+ # Registers Constable as the app's generator test framework, so `rails generate model`
149
+ # writes a case instead of a Minitest file. Conditional on purpose: everything above this
150
+ # line must load in a process with no Rails at all, which is what the :unit tier is for.
151
+ require "constable/railtie" if defined?(Rails::Railtie)
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Rails generator machinery is required *here*, not from lib/constable.rb.
4
+ # `require "constable"` has to work in a process with no Rails app at all --
5
+ # that is the entire point of the :unit tier -- so railties is only ever pulled
6
+ # in by the files that genuinely cannot exist without it.
7
+ require "rails/generators/named_base"
8
+ require "constable"
9
+
10
+ # Rails' own generators get these for free because a booted app has loaded all of
11
+ # ActiveSupport by the time anything generates. Ours can be loaded by a bare
12
+ # `rails generate` in a half-booted process, and GeneratedAttribute#parse reaches for
13
+ # String#remove, so name what we actually depend on rather than hoping.
14
+ require "active_support/core_ext/string/filters"
15
+ require "active_support/core_ext/string/inflections"
16
+
17
+ module Constable
18
+ module Generators
19
+ # Shared ground for the generators Rails invokes on Constable's behalf.
20
+ #
21
+ # Once Constable::Railtie has registered the app's test framework, every
22
+ # `rails generate` command that hooks :test_framework goes looking for a generator
23
+ # called constable:<something>. Rails resolves that by namespace, the namespace comes
24
+ # from the class name, and the file is found by converting the namespace back into a
25
+ # path -- so the layout is not a matter of taste. Each generator lives at
26
+ # generators/constable/<name>/<name>_generator.rb and is called <Name>Generator.
27
+ # rspec-rails is arranged the same way, for the same reason.
28
+ #
29
+ # Named Base deliberately: Rails::Generators::Base.inherited skips registering any
30
+ # class whose name ends in "Base", so this never turns up in `rails generate` output
31
+ # as a generator of its own.
32
+ class Base < ::Rails::Generators::NamedBase
33
+ # Each generator keeps its templates beside itself. Rails' default looks for them
34
+ # under railties' own directory, which is no use to a gem that isn't railties.
35
+ def self.source_root(path = nil)
36
+ return @source_root = path if path
37
+
38
+ @source_root ||= File.expand_path(File.join(__dir__, generator_name, "templates"))
39
+ end
40
+
41
+ # `rails generate job CleanUpJob` and `rails generate job CleanUp` have to land in
42
+ # the same file, so the generators Rails hooks strip their own suffix off the name
43
+ # first. Declared here once rather than written out six times, and with one thing
44
+ # worth knowing: the memo has to be @_file_name and not @file_name, because
45
+ # NamedBase's own reader is backed by @file_name -- memoizing into it would make
46
+ # `super` hand back the already-stripped value and the suffix would never come off.
47
+ # The no_commands wrapper is not decoration: Thor turns every public instance method
48
+ # on a generator into a runnable step, and a generator whose first step is
49
+ # "file_name" writes no files at all.
50
+ def self.strips_suffix(pattern)
51
+ no_commands do
52
+ # rubocop:disable-next Naming/MemoizedInstanceVariableName -- @file_name is taken, see above
53
+ define_method(:file_name) { @_file_name ||= super().sub(pattern, "") }
54
+ end
55
+ private :file_name
56
+ end
57
+
58
+ private
59
+
60
+ # Where a generated case file goes. `test/cases/<area>/...` is the shape the `tiers:`
61
+ # globs in Constable::Config::DEFAULTS already use, so the path-based tier fallback
62
+ # and the tier base class the file inherits from agree with each other instead of
63
+ # quietly disagreeing. The base class still wins; agreeing just means nobody has to
64
+ # work out which one applied.
65
+ def case_path(area, *parts)
66
+ File.join("test/cases", area, *parts)
67
+ end
68
+
69
+ # The attributes worth putting in a witness. References and virtual attributes
70
+ # (rich text, attachments) are dropped: their value is another record or an
71
+ # uploaded file, and inventing one would generate a test that fails for a reason
72
+ # nobody wrote.
73
+ def case_attributes
74
+ @case_attributes ||= attributes.reject { |attribute| attribute.reference? || attribute.virtual? }
75
+ end
76
+
77
+ # `title: "MyString", published: false` -- the body of the witness a case starts from.
78
+ def attributes_arguments
79
+ case_attributes.map { |attribute| "#{attribute.column_name}: #{attribute_value(attribute)}" }.join(", ")
80
+ end
81
+
82
+ def attributes_literal
83
+ case_attributes.empty? ? "{}" : "{ #{attributes_arguments} }"
84
+ end
85
+
86
+ # `Post.new(title: "MyString")`, or a bare `Post.new` for a model generated with
87
+ # nothing to fill in.
88
+ def new_record_expression(klass = class_name)
89
+ case_attributes.empty? ? "#{klass}.new" : "#{klass}.new(#{attributes_arguments})"
90
+ end
91
+
92
+ def attribute_value(attribute)
93
+ return '"secret"' if %w[password password_confirmation].include?(attribute.name)
94
+
95
+ attribute.default.inspect
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "generators/constable/base"
4
+
5
+ module Constable
6
+ module Generators
7
+ # Invoked by `rails generate channel Room`.
8
+ #
9
+ # test/cases/channels/room_channel_case.rb class RoomChannelCase < IntegrationCase
10
+ class ChannelGenerator < Base
11
+ check_class_collision suffix: "ChannelCase"
12
+
13
+ strips_suffix(/_channel\z/i)
14
+
15
+ def create_case_file
16
+ template "channel_case.rb.tt", case_path("channels", class_path, "#{file_name}_channel_case.rb")
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "case_helper"
4
+
5
+ <% module_namespacing do -%>
6
+ class <%= class_name %>ChannelCase < IntegrationCase
7
+ # ActionCable ships its own harness -- `subscribe`, `subscription`, `perform` -- as a
8
+ # module, so it composes into a case the same way any shared behavior does. One line,
9
+ # here or in the IntegrationCase base class in test/case_helper.rb:
10
+ #
11
+ # include ActionCable::Channel::TestCase::Behavior
12
+ #
13
+ # self.channel_class = <%= class_name %>Channel
14
+ #
15
+ # With that in place, the first two investigations write themselves:
16
+ #
17
+ # investigate "subscribes" do
18
+ # subscribe
19
+ #
20
+ # attest(subscription).to be_confirmed
21
+ # end
22
+ #
23
+ # investigate "streams from its own room" do
24
+ # subscribe(room: "1")
25
+ #
26
+ # attest(streams).to include("<%= file_name %>_1")
27
+ # end
28
+ end
29
+ <% end -%>
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "generators/constable/base"
4
+
5
+ module Constable
6
+ module Generators
7
+ # Invoked by `rails generate controller Posts index show`.
8
+ #
9
+ # test/cases/controllers/posts_controller_case.rb class PostsControllerCase < IntegrationCase
10
+ class ControllerGenerator < Base
11
+ argument :actions, type: :array, default: [], banner: "action action"
12
+
13
+ # Rails passes this through when it skipped writing routes. Without a route there is
14
+ # nothing to drive, so the template says so instead of generating a request that
15
+ # cannot resolve.
16
+ class_option :skip_routes, type: :boolean
17
+
18
+ check_class_collision suffix: "ControllerCase"
19
+
20
+ def create_case_file
21
+ template "controller_case.rb.tt", case_path("controllers", class_path, "#{file_name}_controller_case.rb")
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "case_helper"
4
+
5
+ <% module_namespacing do -%>
6
+ class <%= class_name %>ControllerCase < IntegrationCase
7
+ <% if mountable_engine? -%>
8
+ include Engine.routes.url_helpers
9
+
10
+ <% end -%>
11
+ <% if actions.empty? || options[:skip_routes] -%>
12
+ # `rails generate controller <%= name %>` ran <%= actions.empty? ? "without any actions" : "with --skip-routes" %>, so there is no route to
13
+ # drive yet. When there is, an investigation reads like this -- one request, one
14
+ # assertion about the response, nothing shared with the next one:
15
+ #
16
+ # investigate "index responds successfully" do
17
+ # get <%= url_helper_prefix %>_index_url
18
+ #
19
+ # attest(response).to have_http_status(:ok)
20
+ # end
21
+ <% else -%>
22
+ <% actions.each do |action| -%>
23
+ investigate "<%= action %> responds successfully" do
24
+ get <%= url_helper_prefix %>_<%= action %>_url
25
+
26
+ attest(response).to have_http_status(:ok)
27
+ end
28
+ <%= "\n" unless action == actions.last -%>
29
+ <% end -%>
30
+ <% end -%>
31
+ end
32
+ <% end -%>