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,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Constable
4
+ # Catches the test that only passes because something else ran first.
5
+ #
6
+ # A new or changed investigation is run twice in CI: once completely alone, in its own
7
+ # forked process before the suite has touched anything, and once in ordinary full-suite
8
+ # context. The two answers should agree. When they don't, the test is depending on state
9
+ # it never set up, and that is a failure now rather than a mystery three months from now
10
+ # when someone reorders a file.
11
+ #
12
+ # Only new and changed investigations are audited, because identity is a content hash:
13
+ # an unchanged body keeps its hash, and a body that already passed this audit does not
14
+ # need to pay for it on every future run.
15
+ class OrderAudit
16
+ MESSAGE = "ORDER DEPENDENT TEST"
17
+
18
+ attr_reader :config, :storage
19
+
20
+ def initialize(config: Constable.config, storage: Constable.storage, enabled: nil)
21
+ @config = config
22
+ @storage = storage
23
+ @enabled = enabled.nil? ? ci? : enabled
24
+ @isolated = {}
25
+ end
26
+
27
+ def enabled? = @enabled && Process.respond_to?(:fork)
28
+
29
+ def ci? = !ENV["CI"].to_s.empty?
30
+
31
+ # Investigations whose content hash the blotter has never seen -- new tests, or tests
32
+ # whose body actually changed.
33
+ def candidates(investigations)
34
+ return [] unless enabled?
35
+
36
+ known = begin
37
+ @storage.known_identities.map { |entry| entry[:identity] }
38
+ rescue StandardError
39
+ []
40
+ end
41
+ investigations.reject { |inv| known.include?(inv.identity) }
42
+ end
43
+
44
+ # The "alone" half of the audit. Runs before the suite so each child inherits a process
45
+ # that has not yet run a single test.
46
+ def record_isolated!(investigations)
47
+ candidates(investigations).each do |investigation|
48
+ @isolated[investigation.identity] = run_alone(investigation)
49
+ end
50
+ @isolated
51
+ end
52
+
53
+ # The "in context" half. Returns the results rewritten to failures where the two
54
+ # answers disagree.
55
+ def audit(results)
56
+ return results if @isolated.empty?
57
+
58
+ results.map do |result|
59
+ expected = @isolated[result.identity]
60
+ next result if expected.nil?
61
+ next result if expected == simple_status(result)
62
+
63
+ order_dependent(result, expected)
64
+ end
65
+ end
66
+
67
+ def order_dependent?(result)
68
+ expected = @isolated[result.identity]
69
+ !expected.nil? && expected != simple_status(result)
70
+ end
71
+
72
+ private
73
+
74
+ def simple_status(result)
75
+ result.passed? ? :passed : :failed
76
+ end
77
+
78
+ def order_dependent(result, isolated_status)
79
+ in_suite = simple_status(result)
80
+ result.status = :failed
81
+ result.failure = Failure.new(
82
+ message: "#{MESSAGE}: passed #{describe(isolated_status)} but #{describe(in_suite)} " \
83
+ "in full-suite context. This investigation depends on state another test " \
84
+ "leaves behind rather than on its own briefing.",
85
+ context: "alone: #{isolated_status}\nin suite: #{in_suite}",
86
+ backtrace: []
87
+ )
88
+ result
89
+ end
90
+
91
+ def describe(status)
92
+ status == :passed ? "alone" : "failed alone"
93
+ end
94
+
95
+ # Forks a child that runs exactly one investigation and reports back a single byte.
96
+ # A crash in the child is a failure, not a hang in the parent.
97
+ def run_alone(investigation)
98
+ reader, writer = IO.pipe
99
+
100
+ pid = fork do
101
+ reader.close
102
+ instance = Case.constable_instance_for(investigation)
103
+ status =
104
+ begin
105
+ # The same full lifecycle the Runner drives -- before_setup, briefings, body,
106
+ # teardowns, after_teardown -- because an audit that skipped half of it would
107
+ # be comparing two different tests and calling the difference an order bug.
108
+ Isolation.with_rollback(investigation.tier) { instance.run_investigation(investigation) }
109
+ "P"
110
+ rescue StandardError
111
+ "F"
112
+ ensure
113
+ instance._constable_dsl_teardown if instance.respond_to?(:_constable_dsl_teardown)
114
+ end
115
+ writer.write(status)
116
+ writer.close
117
+ exit!(0)
118
+ end
119
+
120
+ writer.close
121
+ byte = reader.read
122
+ reader.close
123
+ Process.waitpid(pid)
124
+
125
+ byte == "P" ? :passed : :failed
126
+ rescue StandardError
127
+ nil
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,213 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Constable
4
+ # Rails' own testing behaviour, borrowed for a Constable::Case.
5
+ #
6
+ # class IntegrationCase < Constable::Case
7
+ # include Constable::RailsSupport::Integration
8
+ # tier :integration
9
+ # end
10
+ #
11
+ # Rails ships the request stack and the browser stack as ordinary modules -- there is no
12
+ # need to reimplement `post users_path` or `click_on "Save"`, and every reimplementation
13
+ # would be a slightly wrong one. What those modules *do* assume is Minitest's lifecycle:
14
+ # `setup`/`teardown` class macros and the `before_setup`/`after_teardown` instance hooks.
15
+ # Constable::Case answers that contract (see its "Minitest lifecycle compatibility"
16
+ # section), so the modules drop straight in.
17
+ #
18
+ # Nothing here is loaded until a case_helper asks for it. `require "constable"` must
19
+ # still work in a process with no Rails at all -- that is the whole premise of the :unit
20
+ # tier -- so every Rails constant below is reached through a lazy require.
21
+ module RailsSupport
22
+ # The request stack: get/post/patch/put/delete, `response`, `follow_redirect!`,
23
+ # cookies, and the application's URL helpers (`articles_url`, `article_path`).
24
+ #
25
+ # Include it into a tier base class, not into an individual case.
26
+ module Integration
27
+ def self.included(base)
28
+ behavior = RailsSupport.integration_behavior
29
+ RailsSupport.shim_fixture_paths(base)
30
+ base.include(behavior)
31
+ end
32
+ end
33
+
34
+ # The browser stack: Capybara's DSL (`visit`, `click_on`, `fill_in`, `page`), Capybara's
35
+ # Minitest assertions, Rails' `driven_by`/`served_by` configuration and its failure
36
+ # screenshots.
37
+ #
38
+ # Rails' own ActionDispatch::SystemTestCase is a class, not a module, so it cannot be
39
+ # mixed in. What it actually does, though, is a short list -- driver selection, a Puma
40
+ # server, session reset, screenshots, URL helpers off the Capybara host -- and each of
41
+ # those is reproduced below against the same Rails objects rather than reinvented.
42
+ module System
43
+ DEFAULT_HOST = "http://127.0.0.1"
44
+
45
+ def self.included(base)
46
+ RailsSupport.load_system!
47
+
48
+ base.extend(ClassMethods)
49
+ base.include(::Capybara::DSL)
50
+ base.include(::Capybara::Minitest::Assertions)
51
+ base.include(::ActionDispatch::SystemTesting::TestHelpers::ScreenshotHelper)
52
+ base.include(::ActionDispatch::SystemTesting::TestHelpers::SetupAndTeardown)
53
+ base.include(InstanceMethods)
54
+ end
55
+
56
+ module ClassMethods
57
+ # Same signature as ActionDispatch::SystemTestCase.driven_by, and it builds the
58
+ # same driver object, so every driver Rails supports is supported here.
59
+ def driven_by(driver, using: :chrome, screen_size: [1400, 1400], options: {}, &capabilities)
60
+ self.constable_driver = ::ActionDispatch::SystemTesting::Driver.new(
61
+ driver, using: using, screen_size: screen_size, options: options, &capabilities
62
+ )
63
+ end
64
+
65
+ def served_by(host:, port:)
66
+ ::Capybara.server_host = host
67
+ ::Capybara.server_port = port
68
+ end
69
+
70
+ # Inherited the same way `tier` is: declare the driver once on SystemCase and every
71
+ # case below it is driven the same way.
72
+ def constable_driver
73
+ return @constable_driver if defined?(@constable_driver) && @constable_driver
74
+
75
+ superclass.respond_to?(:constable_driver) ? superclass.constable_driver : nil
76
+ end
77
+
78
+ attr_writer :constable_driver
79
+ end
80
+
81
+ module InstanceMethods
82
+ # Registering the driver with Capybara is deferred to the first investigation that
83
+ # actually runs, so merely loading a suite that contains system cases never starts
84
+ # a browser.
85
+ def before_setup
86
+ driver = self.class.constable_driver || self.class.driven_by(:selenium)
87
+ driver.use
88
+ super if defined?(super)
89
+ end
90
+
91
+ # Rails' screenshot helper asks the test whether it failed. Constable knows the
92
+ # answer while teardown runs, because the lifecycle stashes the exception first.
93
+ def failed? = !constable_failure.nil?
94
+ def passed? = constable_failure.nil?
95
+
96
+ # Minitest hangs arbitrary reporter data off each test; the screenshot helper
97
+ # writes the failure image path into it. Constable has no such reporter channel,
98
+ # so this is somewhere harmless for it to land.
99
+ # rubocop:disable Naming/MemoizedInstanceVariableName -- class-level and instance
100
+ # state on a Case carries a constable_ prefix so it can never collide with an
101
+ # instance variable a user's own case sets.
102
+ def metadata = (@constable_metadata ||= {})
103
+ # rubocop:enable Naming/MemoizedInstanceVariableName
104
+
105
+ # Capybara's assertions bump Minitest's counter directly (`self.assertions += 1`).
106
+ # Constable keeps the same tally under its own name, so point one at the other
107
+ # rather than let `assert_text` die counting.
108
+ def assertions = assertion_count
109
+
110
+ def assertions=(count)
111
+ @assertion_count = count
112
+ end
113
+
114
+ private
115
+
116
+ # Capybara drives a real server over HTTP, so a system case's URL helpers must
117
+ # generate absolute URLs pointed at that server -- not the relative paths an
118
+ # integration case wants. This is Rails' own arrangement, kept verbatim.
119
+ def url_helpers
120
+ @url_helpers ||= build_url_helpers
121
+ end
122
+
123
+ def build_url_helpers
124
+ app = ::ActionDispatch.test_app
125
+ return nil unless app
126
+
127
+ Class.new do
128
+ include app.routes.url_helpers
129
+ include app.routes.mounted_helpers
130
+
131
+ def url_options = default_url_options.reverse_merge(host: app_host)
132
+
133
+ def app_host
134
+ ::Capybara.app_host || ::Capybara.current_session.server_url || DEFAULT_HOST
135
+ end
136
+ end.new
137
+ end
138
+
139
+ def method_missing(name, ...)
140
+ helpers = url_helpers
141
+ return super unless helpers.respond_to?(name)
142
+
143
+ helpers.public_send(name, ...)
144
+ end
145
+
146
+ def respond_to_missing?(name, include_private = false)
147
+ url_helpers.respond_to?(name) || super
148
+ end
149
+ end
150
+ end
151
+
152
+ class << self
153
+ # ActionDispatch::IntegrationTest::Behavior is an ActiveSupport::Concern, so it has
154
+ # to be included into the *class* rather than into a module in front of it --
155
+ # otherwise its `included` block configures the wrapper module instead of the case.
156
+ def integration_behavior
157
+ require "action_dispatch"
158
+ # Behavior mixes in ActionController::TemplateAssertions without requiring it --
159
+ # inside a booted app something else always has by then.
160
+ require "action_controller"
161
+ require "action_dispatch/testing/integration"
162
+ ::ActionDispatch::IntegrationTest::Behavior
163
+ rescue LoadError, NameError => e
164
+ raise Constable::ConfigurationError, <<~MESSAGE
165
+ Constable::RailsSupport::Integration needs Action Pack's integration test
166
+ helpers, and they could not be loaded (#{e.class}: #{e.message}).
167
+
168
+ An :integration case drives the real request stack, so Rails has to be booted
169
+ before the tier base class is defined. In test/case_helper.rb that means:
170
+
171
+ require_relative "../config/environment"
172
+ require "constable"
173
+
174
+ must come before `class IntegrationCase < Constable::Case`.
175
+ MESSAGE
176
+ end
177
+
178
+ def load_system!
179
+ require "action_dispatch"
180
+ # Loading this file is what starts the Capybara/Puma server plumbing -- Rails does
181
+ # the setup at require time rather than in a hook we could call ourselves.
182
+ require "action_dispatch/system_test_case"
183
+ require "capybara/minitest"
184
+ ::ActionDispatch::SystemTesting::Driver
185
+ rescue LoadError, NameError => e
186
+ raise Constable::ConfigurationError, <<~MESSAGE
187
+ Constable::RailsSupport::System needs Rails' system testing support and
188
+ Capybara, and they could not be loaded (#{e.class}: #{e.message}).
189
+
190
+ System cases are opt-in. Add them to your Gemfile's :test group:
191
+
192
+ gem "capybara"
193
+ gem "selenium-webdriver"
194
+
195
+ and make sure test/case_helper.rb requires config/environment before it defines
196
+ SystemCase.
197
+ MESSAGE
198
+ end
199
+
200
+ # `rails/test_help` registers an :action_dispatch_integration_test load hook that
201
+ # does `self.fixture_paths += ActiveSupport::TestCase.fixture_paths`, on the
202
+ # assumption that anything including Behavior is an ActiveSupport::TestCase. A Case
203
+ # is not one, and Constable does not use Rails fixtures, so give the hook somewhere
204
+ # harmless to write rather than let it take the whole suite down.
205
+ def shim_fixture_paths(base)
206
+ return if base.respond_to?(:fixture_paths)
207
+
208
+ base.singleton_class.send(:attr_accessor, :fixture_paths)
209
+ base.fixture_paths = []
210
+ end
211
+ end
212
+ end
213
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Constable
4
+ # The one file in the gem that cannot exist without Rails.
5
+ #
6
+ # `rails generate scaffold Post title:string` does not know what a test file looks
7
+ # like. It asks whatever generator is registered as the app's test framework, and
8
+ # unless something says otherwise that is always test_unit. So an app can install
9
+ # Constable, write its whole suite in cases, and still have every `rails generate`
10
+ # quietly drop Minitest files into test/ -- for the one framework the app deliberately
11
+ # replaced. Registering here is what closes that gap, and it is exactly what
12
+ # rspec-rails does for the same reason.
13
+ #
14
+ # Loaded conditionally from lib/constable.rb, never unconditionally: `require
15
+ # "constable"` has to keep working in a process with no Rails app at all, which is the
16
+ # whole premise of the :unit tier. That guard is also why there is no
17
+ # `require "rails/railtie"` at the top of this file -- by the time anything reaches
18
+ # here, Rails::Railtie is already defined, and requiring it from a bare Ruby process
19
+ # would only turn a clean NameError into a confusing one from inside railties.
20
+ class Railtie < ::Rails::Railtie
21
+ config.app_generators do |g|
22
+ # `fixture: false` is not an oversight. Constable has no fixtures: a witness builds
23
+ # exactly what one investigation needs and throws it away again, which is the same
24
+ # reason there is no before(:all). Generating a fixtures.yml alongside a case would
25
+ # be handing the suite the shared mutable state it exists to prevent.
26
+ g.test_framework :constable, fixture: false
27
+
28
+ # Rails resolves these two separately from :test_framework -- its own test_unit
29
+ # railtie claims all three. Claiming only the first would leave
30
+ # `rails generate integration_test` and `rails generate system_test` writing
31
+ # Minitest into a Constable app, which is the bug this railtie exists to fix.
32
+ g.integration_tool :constable
33
+ g.system_tests :constable
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Constable
4
+ # The roll call of every case file loaded into this process.
5
+ #
6
+ # `Constable::Case.inherited` reports each new subclass here as it's defined, so by the
7
+ # time the Runner has required the case files it can ask one object what there is to
8
+ # run. Dockets are deliberately left off the roll -- they're anonymous subclasses that
9
+ # belong to the case that opened them, and they're reached through it.
10
+ class Registry
11
+ include Enumerable
12
+
13
+ def initialize
14
+ @cases = []
15
+ end
16
+
17
+ # Called from the `inherited` hook. Dockets and duplicates are ignored.
18
+ def register(case_class)
19
+ return case_class if case_class.respond_to?(:docket?) && case_class.docket?
20
+ return case_class if @cases.include?(case_class)
21
+
22
+ @cases << case_class
23
+ case_class
24
+ end
25
+
26
+ # Top-level case classes in definition order. Tier base classes appear here too --
27
+ # they simply have no investigations of their own.
28
+ def cases = @cases.dup
29
+
30
+ # Every investigation across every loaded case, dockets included.
31
+ def investigations = @cases.flat_map(&:investigations)
32
+
33
+ # Cases that actually declared something to run.
34
+ def sworn_cases = @cases.reject { |klass| klass.investigations.empty? }
35
+
36
+ def investigations_in(file)
37
+ path = file.to_s
38
+ investigations.select { |inv| inv.file.to_s == path || inv.relative_file == path }
39
+ end
40
+
41
+ def find_case(name)
42
+ @cases.find { |klass| klass.constable_display_name == name.to_s || klass.name == name.to_s }
43
+ end
44
+
45
+ def each(&) = @cases.each(&)
46
+
47
+ def size = @cases.size
48
+ def empty? = @cases.empty?
49
+ def include?(case_class) = @cases.include?(case_class)
50
+
51
+ # Test isolation, ours as much as anyone's.
52
+ def clear
53
+ @cases = []
54
+ self
55
+ end
56
+ end
57
+ end