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,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "generators/constable/base"
4
+
5
+ module Constable
6
+ module Generators
7
+ # Invoked by `rails generate generator Awesome`.
8
+ #
9
+ # test/cases/generators/awesome_generator_case.rb class AwesomeGeneratorCase < UnitCase
10
+ class GeneratorGenerator < Base
11
+ check_class_collision suffix: "GeneratorCase"
12
+
13
+ class_option :namespace, type: :boolean, default: true,
14
+ desc: "Namespace generator under lib/generators/name"
15
+
16
+ def create_case_file
17
+ template "generator_case.rb.tt", case_path("generators", class_path, "#{file_name}_generator_case.rb")
18
+ end
19
+
20
+ private
21
+
22
+ def generator_path
23
+ if options[:namespace]
24
+ File.join("generators", regular_class_path, file_name, "#{file_name}_generator")
25
+ else
26
+ File.join("generators", regular_class_path, "#{file_name}_generator")
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "case_helper"
4
+ require "<%= generator_path %>"
5
+
6
+ <% module_namespacing do -%>
7
+ class <%= class_name %>GeneratorCase < UnitCase
8
+ # Rails' generator harness is a module, so it composes into a case rather than needing
9
+ # a base class of its own. Wire it up here:
10
+ #
11
+ # include Rails::Generators::Testing::Behaviour
12
+ # include FileUtils
13
+ #
14
+ # self.generator_class = <%= class_name %>Generator
15
+ # self.destination_root = Rails.root.join("tmp/generators")
16
+ #
17
+ # briefing { prepare_destination }
18
+ #
19
+ # A generator is one of the few things whose test is genuinely about files on disk, so
20
+ # assert on what it wrote and on what is inside it:
21
+ #
22
+ # investigate "writes the file it promises" do
23
+ # run_generator ["Post"]
24
+ #
25
+ # attest(File.read(File.join(destination_root, "app/post.rb"))).to include("class Post")
26
+ # end
27
+ end
28
+ <% end -%>
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "generators/constable/base"
4
+
5
+ module Constable
6
+ module Generators
7
+ # Invoked by `rails generate helper Posts` (and by `rails generate controller`, which
8
+ # hooks the helper generator alongside the test framework).
9
+ #
10
+ # test/cases/helpers/posts_helper_case.rb class PostsHelperCase < UnitCase
11
+ #
12
+ # Rails' own test_unit generator writes nothing here at all. A helper is the cheapest
13
+ # thing in a Rails app to test -- a module, a method, a return value -- so the file is
14
+ # worth having, even when it starts out with the module included and nothing to call.
15
+ class HelperGenerator < Base
16
+ check_class_collision suffix: "HelperCase"
17
+
18
+ def create_case_file
19
+ template "helper_case.rb.tt", case_path("helpers", class_path, "#{file_name}_helper_case.rb")
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "case_helper"
4
+
5
+ <% module_namespacing do -%>
6
+ class <%= class_name %>HelperCase < UnitCase
7
+ # A helper is a plain module, so a helper case is a plain unit case: include it and call
8
+ # the method. No request, no database, no browser -- which is why this is the fastest
9
+ # tier and the one where a helper belongs.
10
+ include <%= class_name %>Helper
11
+
12
+ # `rails generate helper <%= name %>` writes an empty module, so there is nothing to
13
+ # call yet. The first method it gains gets an investigation like this:
14
+ #
15
+ # investigate "formats a badge number" do
16
+ # attest(badge_number(1)).to eq("No. 1")
17
+ # end
18
+ end
19
+ <% end -%>
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators/base"
4
+ require "constable"
5
+
6
+ module Constable
7
+ module Generators
8
+ # `rails generate constable:import --from=rspec`
9
+ #
10
+ # A thin front door onto Constable::Importer, for people who reach for
11
+ # `rails generate` before `constable import`. The two are the same operation;
12
+ # this one exists so the install step and the import step are spelled the
13
+ # same way.
14
+ #
15
+ # The default strategy is "reopen": files become Constable::ColdCase::*
16
+ # subclasses (a superclass swap, or a zero-file-change glob in config) and run
17
+ # immediately, verbatim, through their own engine. Nothing is rewritten and
18
+ # nothing can be broken by a bad conversion. The AST rewrite into the native
19
+ # DSL is a separate, opt-in step -- `constable modernize PATH` -- taken one
20
+ # file at a time once the suite is already green under Constable.
21
+ class ImportGenerator < Rails::Generators::Base
22
+ source_root File.expand_path("templates", __dir__)
23
+
24
+ SOURCES = %w[rspec minitest].freeze
25
+ STRATEGIES = %w[reopen modernize].freeze
26
+
27
+ desc <<~DESC
28
+ Imports an existing RSpec or Minitest suite as cold cases. The default "reopen"
29
+ strategy changes no test code: files run verbatim through their own engine, with
30
+ pass/fail/timing merged into Constable's reporting, flake history and CI gate.
31
+
32
+ Run with --dry-run first to see exactly which files would be touched.
33
+ DESC
34
+
35
+ class_option :from, type: :string, default: "rspec",
36
+ desc: "Which suite to import: #{SOURCES.join(" | ")}"
37
+ class_option :strategy, type: :string, default: "reopen",
38
+ desc: "#{STRATEGIES.join(" | ")} -- reopen is verbatim, modernize rewrites"
39
+ class_option :dry_run, type: :boolean, default: false,
40
+ desc: "Report what would happen without writing anything"
41
+
42
+ def import
43
+ validate_options!
44
+ report(invoke_importer)
45
+ end
46
+
47
+ private
48
+
49
+ def validate_options!
50
+ unless SOURCES.include?(options[:from])
51
+ raise Thor::Error, "--from must be one of #{SOURCES.join(", ")} (got #{options[:from].inspect})"
52
+ end
53
+
54
+ return if STRATEGIES.include?(options[:strategy])
55
+
56
+ raise Thor::Error, "--strategy must be one of #{STRATEGIES.join(", ")} (got #{options[:strategy].inspect})"
57
+ end
58
+
59
+ def importer_arguments
60
+ {
61
+ from: options[:from].to_sym,
62
+ config: Constable.config,
63
+ dry_run: options[:dry_run],
64
+ strategy: options[:strategy].to_sym
65
+ }
66
+ end
67
+
68
+ # The importer is built independently of this generator, so the call site
69
+ # checks the contract instead of assuming it. A signature that has drifted
70
+ # should read as "the importer changed shape, here is how" -- not as an
71
+ # ArgumentError from three frames down with no indication of whose fault
72
+ # it is.
73
+ def invoke_importer
74
+ importer = load_importer!
75
+ assert_signature!(importer)
76
+ importer.run(**importer_arguments)
77
+ rescue ArgumentError => e
78
+ raise Thor::Error, <<~MSG.chomp
79
+ Constable::Importer.run rejected the arguments this generator passes
80
+ (#{importer_arguments.keys.map(&:inspect).join(", ")}): #{e.message}
81
+
82
+ The generator and the importer have drifted apart. Fix the call in
83
+ lib/generators/constable/import_generator.rb to match Importer.run.
84
+ MSG
85
+ end
86
+
87
+ def load_importer!
88
+ importer = Constable::Importer
89
+ return importer if importer.respond_to?(:run)
90
+
91
+ raise Thor::Error, "Constable::Importer does not respond to .run -- this build of the gem is incomplete."
92
+ rescue LoadError, NameError => e
93
+ raise Thor::Error, "Constable::Importer could not be loaded (#{e.class}: #{e.message})."
94
+ end
95
+
96
+ def assert_signature!(importer)
97
+ parameters = importer.method(:run).parameters
98
+ return if parameters.any? { |type, _| type == :keyrest } # **opts swallows anything
99
+
100
+ accepted = parameters.filter_map { |type, name| name if %i[key keyreq].include?(type) }
101
+ missing = importer_arguments.keys - accepted
102
+ required = parameters.filter_map { |type, name| name if type == :keyreq }
103
+ unfilled = required - importer_arguments.keys
104
+
105
+ return if missing.empty? && unfilled.empty?
106
+
107
+ lines = ["Constable::Importer.run has a different signature than this generator expects.",
108
+ " generator passes: #{importer_arguments.keys.map(&:inspect).join(", ")}",
109
+ " importer accepts: #{accepted.map(&:inspect).join(", ")}"]
110
+ lines << " not accepted: #{missing.map(&:inspect).join(", ")}" unless missing.empty?
111
+ lines << " required, unset: #{unfilled.map(&:inspect).join(", ")}" unless unfilled.empty?
112
+ lines << "Fix the call in lib/generators/constable/import_generator.rb to match Importer.run."
113
+
114
+ raise Thor::Error, lines.join("\n")
115
+ end
116
+
117
+ # The importer owns the detail of what it did; this only makes sure the run
118
+ # says something, since a generator that prints nothing reads as a generator
119
+ # that did nothing.
120
+ def report(result)
121
+ return if options[:quiet]
122
+
123
+ say ""
124
+ if options[:dry_run]
125
+ say " Dry run -- nothing was written.", :yellow
126
+ else
127
+ say " Imported #{options[:from]} as cold cases (strategy: #{options[:strategy]}).", :green
128
+ end
129
+ say " #{result}" if result.is_a?(String) && !result.empty?
130
+ say ""
131
+ say " Every cold-case file is reported as a warning on every run -- one per file,"
132
+ say " not one per test -- until it is either modernized or deliberately kept."
133
+ say ""
134
+ end
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,188 @@
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 two files that genuinely cannot exist without it.
7
+ require "rails/generators/base"
8
+ require "constable"
9
+
10
+ module Constable
11
+ module Generators
12
+ # `rails generate constable:install`
13
+ #
14
+ # Writes the six things a Constable suite needs to run, and nothing else:
15
+ #
16
+ # test/case_helper.rb the boot file + per-tier base classes
17
+ # test/support/matchers.rb custom matcher examples
18
+ # test/support/authenticatable.rb the "shared behavior is a module" example
19
+ # test/cases/example_case.rb a worked case so `constable test` does something
20
+ # .constable/config.yml every setting, at its default, as a reference
21
+ # .rubocop.yml the linter, merged into yours if you have one
22
+ #
23
+ # Plus the optional :cold_case Gemfile group, but only when there is actually
24
+ # an RSpec or Minitest suite in the repo to import. Installing the gem into a
25
+ # greenfield app shouldn't add two dependencies for a migration that will
26
+ # never happen.
27
+ class InstallGenerator < Rails::Generators::Base
28
+ source_root File.expand_path("templates", __dir__)
29
+
30
+ desc <<~DESC
31
+ Sets up Constable: test/case_helper.rb with the per-tier base classes, test/support/
32
+ with matcher and shared-module examples, an example case, the full .constable/config.yml
33
+ reference, a .rubocop.yml wired to rubocop-constable, and -- only when this repo already
34
+ has RSpec or Minitest files to import -- the optional :cold_case Gemfile group.
35
+
36
+ --force, --pretend, --quiet and --skip come from Rails and behave as they do everywhere.
37
+ DESC
38
+
39
+ class_option :skip_gemfile, type: :boolean, default: false,
40
+ desc: "Don't append the optional :cold_case group to the Gemfile"
41
+ class_option :skip_rubocop, type: :boolean, default: false,
42
+ desc: "Don't create or modify .rubocop.yml"
43
+ class_option :skip_example, type: :boolean, default: false,
44
+ desc: "Don't write the example case under test/cases/"
45
+ class_option :skip_support, type: :boolean, default: false,
46
+ desc: "Don't write the example files under test/support/"
47
+
48
+ COLD_CASE_GROUP = <<~RUBY
49
+ # Cold cases: your existing RSpec/Minitest files, run verbatim through their own
50
+ # real engine, with results merged into Constable's reporting and CI gate. These
51
+ # two gems are needed only for as long as cold cases exist -- delete the group
52
+ # once the suite is fully modernized and both dependencies drop out with it.
53
+ group :cold_case do
54
+ gem "rspec-rails"
55
+ gem "minitest"
56
+ end
57
+ RUBY
58
+
59
+ RUBOCOP_EXTENSION = "rubocop-constable"
60
+
61
+ def create_case_helper
62
+ template "case_helper.rb.tt", "test/case_helper.rb"
63
+ end
64
+
65
+ def create_support_files
66
+ return if options[:skip_support]
67
+
68
+ template "matchers.rb.tt", "test/support/matchers.rb"
69
+ template "authenticatable.rb.tt", "test/support/authenticatable.rb"
70
+ end
71
+
72
+ def create_config
73
+ template "config.yml.tt", ".constable/config.yml"
74
+ end
75
+
76
+ def create_example_case
77
+ return if options[:skip_example]
78
+
79
+ template "example_case.rb.tt", "test/cases/example_case.rb"
80
+ end
81
+
82
+ # The :cold_case group is appended only when there is something to import,
83
+ # and only once no matter how many times the generator runs. Re-running an
84
+ # installer is normal (a new option, a regenerated helper) and must never
85
+ # leave a Gemfile with the same group in it twice.
86
+ def add_cold_case_gemfile_group
87
+ return if options[:skip_gemfile]
88
+
89
+ unless File.exist?(File.join(destination_root, "Gemfile"))
90
+ say_status :skip, "Gemfile not found -- add the :cold_case group by hand before importing", :yellow
91
+ return
92
+ end
93
+
94
+ unless legacy_suite_present?
95
+ say_status :skip, "Gemfile (no RSpec or Minitest files here to import as cold cases)", :blue
96
+ return
97
+ end
98
+
99
+ if gemfile_contents.match?(/^\s*group\s+:cold_case\b/)
100
+ say_status :identical, "Gemfile (:cold_case group already present)", :blue
101
+ return
102
+ end
103
+
104
+ append_to_file "Gemfile", "\n#{COLD_CASE_GROUP}"
105
+ end
106
+
107
+ # An app that already lints has opinions in .rubocop.yml worth more than
108
+ # ours. Merge into it; never clobber it.
109
+ def configure_rubocop
110
+ return if options[:skip_rubocop]
111
+
112
+ path = File.join(destination_root, ".rubocop.yml")
113
+ return template("rubocop.yml.tt", ".rubocop.yml") unless File.exist?(path)
114
+
115
+ existing = File.read(path)
116
+ if existing.include?(RUBOCOP_EXTENSION)
117
+ say_status :identical, ".rubocop.yml (already requires #{RUBOCOP_EXTENSION})", :blue
118
+ return
119
+ end
120
+
121
+ create_file ".rubocop.yml", merge_rubocop(existing), force: true
122
+ end
123
+
124
+ def print_next_steps
125
+ return if options[:quiet]
126
+
127
+ say ""
128
+ say " Constable is installed.", :green
129
+ say ""
130
+ say " constable test run everything changed since the merge-base"
131
+ say " constable test --full run the whole suite (this is what CI does)"
132
+ say ""
133
+ say " test/case_helper.rb is worth reading before you write a case -- it explains"
134
+ say " the tier base classes, and the two things Constable deliberately doesn't have."
135
+ say ""
136
+ end
137
+
138
+ private
139
+
140
+ def gemfile_contents
141
+ File.read(File.join(destination_root, "Gemfile"))
142
+ end
143
+
144
+ # "Is there anything here to import?" -- any RSpec or Minitest file at all.
145
+ # Native Constable cases are named *_case.rb, so the example this generator
146
+ # just wrote can never be mistaken for a legacy suite.
147
+ def legacy_suite_present?
148
+ Dir.glob(File.join(destination_root, "{spec,test}/**/*_{spec,test}.rb")).any?
149
+ end
150
+
151
+ # A textual merge, not a YAML round trip: parsing and re-emitting someone's
152
+ # .rubocop.yml would silently eat every comment in it, and comments in a
153
+ # lint config are usually the reason a rule is there at all.
154
+ def merge_rubocop(existing)
155
+ lines = existing.lines
156
+
157
+ # `require:` heading an indented list -- append one more item to the end
158
+ # of it, so load order for everything already there is unchanged.
159
+ index = lines.index { |line| line.match?(/\Arequire:[ \t]*(#.*)?$/) }
160
+ if index
161
+ last = index
162
+ indent = " "
163
+ ((index + 1)...lines.length).each do |i|
164
+ item = lines[i].match(/\A([ \t]+)-[ \t]/)
165
+ break unless item || lines[i].match?(/\A[ \t]*#/)
166
+ next unless item
167
+
168
+ last = i
169
+ indent = item[1]
170
+ end
171
+ lines.insert(last + 1, "#{indent}- #{RUBOCOP_EXTENSION}\n")
172
+ return lines.join
173
+ end
174
+
175
+ # `require: some-extension` on one line -- promote it to a list of both.
176
+ index = lines.index { |line| line.match?(/\Arequire:[ \t]*[^\s#]/) }
177
+ if index
178
+ current = lines[index].sub(/\Arequire:[ \t]*/, "").sub(/[ \t]*#.*\z/m, "").strip
179
+ lines[index] = "require:\n - #{current}\n - #{RUBOCOP_EXTENSION}\n"
180
+ return lines.join
181
+ end
182
+
183
+ # No `require:` at all -- append the whole snippet, existing file intact.
184
+ "#{existing.chomp}\n\n#{File.read(File.join(self.class.source_root, "rubocop.yml.tt"))}"
185
+ end
186
+ end
187
+ end
188
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "generators/constable/base"
4
+
5
+ module Constable
6
+ module Generators
7
+ # Invoked by `rails generate integration_test Checkout`, which hooks :integration_tool
8
+ # as :integration -- so the command Rails calls integration_test resolves to a
9
+ # generator that has to be called IntegrationGenerator.
10
+ #
11
+ # test/cases/controllers/checkout_case.rb class CheckoutCase < IntegrationCase
12
+ #
13
+ # It lands under test/cases/controllers because that is the directory the `tiers:`
14
+ # globs in Constable::Config::DEFAULTS map to :integration. A request case is exactly
15
+ # what that directory is for -- driving the real stack from the outside, which is the
16
+ # level Constable's own spec example works at.
17
+ class IntegrationGenerator < Base
18
+ check_class_collision suffix: "Case"
19
+
20
+ strips_suffix(/_test\z/i)
21
+
22
+ def create_case_file
23
+ template "request_case.rb.tt", case_path("controllers", class_path, "#{file_name}_case.rb")
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "case_helper"
4
+
5
+ <% module_namespacing do -%>
6
+ class <%= class_name %>Case < IntegrationCase
7
+ # A request case drives the whole stack from the outside -- routes, middleware,
8
+ # controller, view -- which is why it is :integration tier and gets a transaction rolled
9
+ # back around every investigation.
10
+ #
11
+ # The generator does not know which route this covers. Once you do, it reads like this:
12
+ #
13
+ # witness(:<%= singular_name %>) { <%= class_name.singularize %>.create!(name: "MyString") }
14
+ #
15
+ # investigate "GET /<%= plural_file_name %> lists every <%= singular_name %>" do
16
+ # get <%= plural_file_name %>_url
17
+ #
18
+ # attest(response).to have_http_status(:ok)
19
+ # attest(response.body).to include(<%= singular_name %>.name)
20
+ # end
21
+ end
22
+ <% 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 job CleanUp`.
8
+ #
9
+ # test/cases/jobs/clean_up_job_case.rb class CleanUpJobCase < IntegrationCase
10
+ class JobGenerator < Base
11
+ check_class_collision suffix: "JobCase"
12
+
13
+ strips_suffix(/_job\z/i)
14
+
15
+ def create_case_file
16
+ template "job_case.rb.tt", case_path("jobs", class_path, "#{file_name}_job_case.rb")
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "case_helper"
4
+
5
+ <% module_namespacing do -%>
6
+ class <%= class_name %>JobCase < IntegrationCase
7
+ # `rails generate job <%= name %>` writes an empty #perform, so there is nothing to
8
+ # assert on yet. When there is, a job has two halves worth testing and they are two
9
+ # different investigations.
10
+ #
11
+ # What perform actually does -- run it directly, assert on the effect:
12
+ #
13
+ # investigate "marks the record as processed" do
14
+ # record = <%= class_name %>.create!
15
+ #
16
+ # <%= class_name %>Job.perform_now(record)
17
+ #
18
+ # attest(record.reload).to be_processed
19
+ # end
20
+ #
21
+ # That it gets enqueued at all -- this one needs ActiveJob's test adapter, which is a
22
+ # line in the IntegrationCase base class in test/case_helper.rb (or here, if only jobs
23
+ # want it):
24
+ #
25
+ # include ActiveJob::TestHelper
26
+ #
27
+ # investigate "goes on the queue it declares" do
28
+ # assert_difference("enqueued_jobs.size", 1) do
29
+ # <%= class_name %>Job.perform_later
30
+ # end
31
+ # end
32
+ end
33
+ <% 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 mailbox Forwards`.
8
+ #
9
+ # test/cases/mailboxes/forwards_mailbox_case.rb class ForwardsMailboxCase < IntegrationCase
10
+ class MailboxGenerator < Base
11
+ check_class_collision suffix: "MailboxCase"
12
+
13
+ strips_suffix(/_mailbox\z/i)
14
+
15
+ def create_case_file
16
+ template "mailbox_case.rb.tt", case_path("mailboxes", class_path, "#{file_name}_mailbox_case.rb")
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "case_helper"
4
+
5
+ <% module_namespacing do -%>
6
+ class <%= class_name %>MailboxCase < IntegrationCase
7
+ # ActionMailbox's helpers for building an inbound email are a plain module, so they
8
+ # compose into a case like any other shared behavior. One line, here or in the
9
+ # IntegrationCase base class in test/case_helper.rb:
10
+ #
11
+ # include ActionMailbox::TestHelper
12
+ #
13
+ # Then the investigation worth writing is about what the mailbox did with the message,
14
+ # not that it received one:
15
+ #
16
+ # witness(:inbound) do
17
+ # create_inbound_email_from_mail(to: "<%= file_name %>@example.com", subject: "Hello")
18
+ # end
19
+ #
20
+ # investigate "files the message against its sender" do
21
+ # inbound.route
22
+ #
23
+ # attest(inbound.reload).to be_delivered
24
+ # end
25
+ end
26
+ <% end -%>
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "generators/constable/base"
4
+
5
+ module Constable
6
+ module Generators
7
+ # Invoked by `rails generate mailer Notifier welcome`.
8
+ #
9
+ # test/cases/mailers/notifier_mailer_case.rb class NotifierMailerCase < IntegrationCase
10
+ # test/mailers/previews/notifier_mailer_preview.rb the /rails/mailers preview
11
+ class MailerGenerator < Base
12
+ argument :actions, type: :array, default: [], banner: "method method"
13
+
14
+ strips_suffix(/_mailer\z/i)
15
+
16
+ def check_class_collision
17
+ class_collisions "#{class_name}MailerCase", "#{class_name}MailerPreview"
18
+ end
19
+
20
+ def create_case_file
21
+ template "mailer_case.rb.tt", case_path("mailers", class_path, "#{file_name}_mailer_case.rb")
22
+ end
23
+
24
+ # A preview is a development tool, not a case, and ActionMailer looks for it under
25
+ # test/mailers/previews by default. Moving it somewhere more Constable-shaped would
26
+ # buy nothing and break /rails/mailers, so it stays exactly where Rails put it.
27
+ def create_preview_file
28
+ template "preview.rb.tt", File.join("test/mailers/previews", class_path, "#{file_name}_mailer_preview.rb")
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "case_helper"
4
+
5
+ <% module_namespacing do -%>
6
+ class <%= class_name %>MailerCase < IntegrationCase
7
+ <% if actions.empty? -%>
8
+ # `rails generate mailer <%= name %>` ran with no methods, so there is nothing to send
9
+ # yet. When there is, an investigation reads like this:
10
+ #
11
+ # investigate "welcome" do
12
+ # mail = <%= class_name %>Mailer.welcome
13
+ #
14
+ # attest(mail.subject).to eq("Welcome")
15
+ # attest(mail.to).to eq(["to@example.org"])
16
+ # attest(mail.body.encoded).to include("Hi")
17
+ # end
18
+ <% else -%>
19
+ # Rails wrote these headers into the generated views; they are placeholders and every
20
+ # one of these assertions is meant to be rewritten as the mailer becomes real.
21
+ <% actions.each_with_index do |action, index| -%>
22
+ <%= "\n" unless index.zero? -%>
23
+ investigate "<%= action %>" do
24
+ mail = <%= class_name %>Mailer.<%= action %>
25
+
26
+ attest(mail.subject).to eq(<%= action.to_s.humanize.inspect %>)
27
+ attest(mail.to).to eq(["to@example.org"])
28
+ attest(mail.from).to eq(["from@example.com"])
29
+ attest(mail.body.encoded).to include("Hi")
30
+ end
31
+ <% end -%>
32
+ <% end -%>
33
+ end
34
+ <% end -%>
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ <% module_namespacing do -%>
4
+ # Preview all emails at http://localhost:3000/rails/mailers/<%= file_path %>_mailer
5
+ class <%= class_name %>MailerPreview < ActionMailer::Preview
6
+ <% actions.each_with_index do |action, index| -%>
7
+ <%= "\n" unless index.zero? -%>
8
+ # Preview this email at http://localhost:3000/rails/mailers/<%= file_path %>_mailer/<%= action %>
9
+ def <%= action %>
10
+ <%= class_name %>Mailer.<%= action %>
11
+ end
12
+ <% end -%>
13
+ end
14
+ <% end -%>