constable-rails 0.1.0 → 1.0.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.
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Constable
4
+ # Per-worker databases for parallel runs.
5
+ #
6
+ # Forking N workers that all talk to one database is not a speed/safety trade, it is a
7
+ # correctness bug. On SQLite it shows up immediately and honestly -- every worker
8
+ # contends for the same file and the run dissolves into
9
+ # `SQLite3::BusyException: database is locked` -- and on a client/server database it
10
+ # shows up later and far worse, as tests seeing each other's rows.
11
+ #
12
+ # Rails already solved this for `rails test`: each worker gets its own database, named
13
+ # by appending the worker index, rebuilt from schema. This is the same thing, driven by
14
+ # Constable's runner rather than by ActiveSupport::Testing::Parallelization, because
15
+ # Constable does its own forking.
16
+ #
17
+ # Everything here is defensive. Constable runs in apps with no ActiveRecord at all --
18
+ # that is the whole point of the :unit tier -- so every entry point answers "no" rather
19
+ # than raising when the pieces are missing, and the runner falls back to a serial run.
20
+ module WorkerDatabases
21
+ module_function
22
+
23
+ # Is there an ActiveRecord in this process whose databases would be shared by forks?
24
+ def active_record?
25
+ defined?(::ActiveRecord::Base) ? true : false
26
+ end
27
+
28
+ # Can we actually give each worker its own database? Rails ships the machinery in
29
+ # active_record/test_databases, which is only loaded when someone asks for parallel
30
+ # tests -- so ask for it here rather than assuming.
31
+ def shardable?
32
+ return false unless active_record?
33
+
34
+ load_test_databases!
35
+ defined?(::ActiveRecord::TestDatabases) ? true : false
36
+ end
37
+
38
+ # Parent side, before the fork. A child inheriting a live connection is a corruption
39
+ # risk in exactly the way an inherited SQLite handle is.
40
+ def before_fork!
41
+ return false unless active_record?
42
+
43
+ ::ActiveRecord::Base.connection_handler.clear_all_connections!
44
+ true
45
+ rescue StandardError
46
+ false
47
+ end
48
+
49
+ # Child side, immediately after the fork and before any test runs. Builds
50
+ # `<database>_<index>` from schema and points this process at it.
51
+ #
52
+ # ENV["VERBOSE"] is silenced the way Rails silences it: schema loading is chatty, and
53
+ # stdout belongs to the reporter.
54
+ def after_fork!(index)
55
+ return false unless shardable?
56
+
57
+ ::ActiveRecord::TestDatabases.create_and_load_schema(index, env_name: env_name)
58
+ true
59
+ rescue StandardError => e
60
+ # A worker that cannot build its own database would otherwise silently fall back to
61
+ # sharing the parent's, which is the bug this module exists to prevent. Say so, and
62
+ # let the failure be a real one.
63
+ raise Constable::Error, "worker #{index} could not create its own test database: " \
64
+ "#{e.class}: #{e.message}"
65
+ end
66
+
67
+ def env_name
68
+ if defined?(::ActiveRecord::ConnectionHandling::DEFAULT_ENV)
69
+ ::ActiveRecord::ConnectionHandling::DEFAULT_ENV.call
70
+ else
71
+ ENV["RAILS_ENV"] || "test"
72
+ end
73
+ end
74
+
75
+ def load_test_databases!
76
+ return if defined?(::ActiveRecord::TestDatabases)
77
+
78
+ require "active_record/test_databases"
79
+ rescue LoadError, StandardError
80
+ nil
81
+ end
82
+ end
83
+ end
data/lib/constable.rb CHANGED
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "constable/version"
4
+ require "constable/worker_databases"
4
5
 
5
6
  # Constable -- an opinionated, strict Rails testing framework.
6
7
  #
@@ -19,6 +19,7 @@ module Constable
19
19
  # test/cases/example_case.rb a worked case so `constable test` does something
20
20
  # .constable/config.yml every setting, at its default, as a reference
21
21
  # .rubocop.yml the linter, merged into yours if you have one
22
+ # .gitignore two lines, so the blotter stays local
22
23
  #
23
24
  # Plus the optional :cold_case Gemfile group, but only when there is actually
24
25
  # an RSpec or Minitest suite in the repo to import. Installing the gem into a
@@ -45,17 +46,32 @@ module Constable
45
46
  class_option :skip_support, type: :boolean, default: false,
46
47
  desc: "Don't write the example files under test/support/"
47
48
 
48
- COLD_CASE_GROUP = <<~RUBY
49
+ COLD_CASE_HEADER = <<~RUBY
49
50
  # Cold cases: your existing RSpec/Minitest files, run verbatim through their own
50
51
  # 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
52
+ # gems are needed only for as long as cold cases exist -- delete the group once
53
+ # the suite is fully modernized and the dependencies drop out with it.
57
54
  RUBY
58
55
 
56
+ # Only the engines this repo actually has files for, and only ones the Gemfile does
57
+ # not already declare. An app adopting Constable *from RSpec* -- which is most of
58
+ # them -- already has rspec-rails, and declaring it twice is not a style question:
59
+ # Bundler refuses to parse the file at all, so the install leaves the app unbootable.
60
+ COLD_CASE_GEMS = { rspec: "rspec-rails", minitest: "minitest" }.freeze
61
+
62
+ # Per engine, so a repo with only RSpec files does not get minitest added to its
63
+ # Gemfile for a migration it is never going to do.
64
+ LEGACY_GLOBS = { rspec: "spec/**/*_spec.rb", minitest: "test/**/*_test.rb" }.freeze
65
+
66
+ # The blotter is machine state: this laptop's flake history and jail docket. Sharing
67
+ # it through git would hand CI somebody else's docket and conflict on every run.
68
+ GITIGNORE_ENTRY = <<~TEXT
69
+ # Constable's blotter -- flake history, the jail docket, warrants. Local state:
70
+ # each machine keeps its own, and CI starts clean.
71
+ /.constable/*.sqlite3
72
+ /.constable/*.sqlite3-*
73
+ TEXT
74
+
59
75
  RUBOCOP_EXTENSION = "rubocop-constable"
60
76
 
61
77
  def create_case_helper
@@ -101,7 +117,31 @@ module Constable
101
117
  return
102
118
  end
103
119
 
104
- append_to_file "Gemfile", "\n#{COLD_CASE_GROUP}"
120
+ gems = cold_case_gems_to_add
121
+ if gems.empty?
122
+ say_status :skip, "Gemfile (every cold-case engine is already declared)", :blue
123
+ return
124
+ end
125
+
126
+ append_to_file "Gemfile", "\n#{cold_case_group(gems)}"
127
+ end
128
+
129
+ # The blotter must not be committed. Appended rather than templated, because an app
130
+ # always has a .gitignore already and ours is two lines of it.
131
+ def ignore_the_blotter
132
+ path = File.join(destination_root, ".gitignore")
133
+
134
+ unless File.exist?(path)
135
+ create_file ".gitignore", GITIGNORE_ENTRY
136
+ return
137
+ end
138
+
139
+ if File.read(path).include?("/.constable/*.sqlite3")
140
+ say_status :identical, ".gitignore (blotter already ignored)", :blue
141
+ return
142
+ end
143
+
144
+ append_to_file ".gitignore", "\n#{GITIGNORE_ENTRY}"
105
145
  end
106
146
 
107
147
  # An app that already lints has opinions in .rubocop.yml worth more than
@@ -137,6 +177,33 @@ module Constable
137
177
 
138
178
  private
139
179
 
180
+ # An engine earns a line only if this repo has files for it and the Gemfile does not
181
+ # already declare it.
182
+ def cold_case_gems_to_add
183
+ COLD_CASE_GEMS.filter_map do |engine, gem_name|
184
+ next unless legacy_files?(engine)
185
+ next if gem_declared?(gem_name)
186
+
187
+ gem_name
188
+ end
189
+ end
190
+
191
+ def cold_case_group(gems)
192
+ lines = gems.map { |gem_name| %( gem "#{gem_name}") }
193
+ "#{COLD_CASE_HEADER}group :cold_case do\n#{lines.join("\n")}\nend\n"
194
+ end
195
+
196
+ # Matches `gem "rspec-rails"` and `gem 'rspec-rails', "~> 8.0"` alike, and ignores a
197
+ # commented-out line, which is a suggestion rather than a declaration.
198
+ def gem_declared?(gem_name)
199
+ gemfile_contents.each_line.any? do |line|
200
+ stripped = line.strip
201
+ next false if stripped.start_with?("#")
202
+
203
+ stripped.match?(/\Agem\s+["']#{Regexp.escape(gem_name)}["']/)
204
+ end
205
+ end
206
+
140
207
  def gemfile_contents
141
208
  File.read(File.join(destination_root, "Gemfile"))
142
209
  end
@@ -148,6 +215,10 @@ module Constable
148
215
  Dir.glob(File.join(destination_root, "{spec,test}/**/*_{spec,test}.rb")).any?
149
216
  end
150
217
 
218
+ def legacy_files?(engine)
219
+ Dir.glob(File.join(destination_root, LEGACY_GLOBS.fetch(engine))).any?
220
+ end
221
+
151
222
  # A textual merge, not a YAML round trip: parsing and re-emitting someone's
152
223
  # .rubocop.yml would silently eat every comment in it, and comments in a
153
224
  # lint config are usually the reason a rule is there at all.
@@ -67,9 +67,37 @@ end
67
67
  # reimplementation of them would be a slightly wrong one.
68
68
  # -----------------------------------------------------------------------------
69
69
 
70
+ # -----------------------------------------------------------------------------
71
+ # Support files -- shared modules and custom matchers.
72
+ #
73
+ # Same role RSpec's spec/support/**/*.rb plays. Shared behavior *across* files
74
+ # is a plain module you `include`; there is deliberately no shared-examples DSL
75
+ # here, because Ruby's own composition tools already do that job with fewer
76
+ # rules to learn and more flexibility once the reuse stops being simple.
77
+ #
78
+ # Sorted on purpose: Dir[] returns filesystem order, which differs between your
79
+ # laptop and CI. A suite that loads its own support files in an unpredictable
80
+ # order has already lost the argument about determinism.
81
+ #
82
+ # Loaded BEFORE the tier classes below, so `include Authenticatable` on a tier
83
+ # is a thing you can actually write. Loading them afterwards would make the
84
+ # advice in test/support/authenticatable.rb raise NameError.
85
+ # -----------------------------------------------------------------------------
86
+
87
+ Dir[Rails.root.join("test/support/**/*.rb")].sort.each { |f| require f }
88
+
89
+ # Factories, when the app has them. `create(:user)` is what a converted spec will be
90
+ # full of, and a NoMethodError on the first native run is a bad way to learn that the
91
+ # include was missing. Guarded, so a suite with no factory gem is unaffected.
92
+ module CaseFactories
93
+ include FactoryBot::Syntax::Methods if defined?(FactoryBot::Syntax::Methods)
94
+ end
95
+
70
96
  # No database, no request stack, no browser -- just Ruby. The fastest thing
71
97
  # Constable can run, and where most of a suite should live.
72
98
  class UnitCase < Constable::Case
99
+ include CaseFactories
100
+
73
101
  tier :unit
74
102
  end
75
103
 
@@ -78,6 +106,12 @@ end
78
106
  # investigation runs inside a transaction that is rolled back afterwards.
79
107
  class IntegrationCase < Constable::Case
80
108
  include Constable::RailsSupport::Integration
109
+ include CaseFactories
110
+
111
+ # Every integration case gets the app's sign-in helpers. Uncomment once
112
+ # test/support/authenticatable.rb says something true about your app:
113
+ #
114
+ # include Authenticatable
81
115
 
82
116
  tier :integration
83
117
  end
@@ -87,6 +121,8 @@ end
87
121
  # and Rails' own `driven_by`. There is no `response` here -- a system case looks
88
122
  # at the rendered page, which is the whole reason to pay for a browser.
89
123
  class SystemCase < Constable::Case
124
+ include CaseFactories
125
+
90
126
  # Capybara is not a Constable dependency. System cases are opt-in, and a suite
91
127
  # with no browser tests shouldn't be made to install a browser driver to boot.
92
128
  include Constable::RailsSupport::System if defined?(Capybara)
@@ -99,21 +135,6 @@ class SystemCase < Constable::Case
99
135
  # driven_by :selenium, using: :headless_chrome, screen_size: [1400, 1400]
100
136
  end
101
137
 
102
- # -----------------------------------------------------------------------------
103
- # Support files -- shared modules and custom matchers.
104
- #
105
- # Same role RSpec's spec/support/**/*.rb plays. Shared behavior *across* files
106
- # is a plain module you `include`; there is deliberately no shared-examples DSL
107
- # here, because Ruby's own composition tools already do that job with fewer
108
- # rules to learn and more flexibility once the reuse stops being simple.
109
- #
110
- # Sorted on purpose: Dir[] returns filesystem order, which differs between your
111
- # laptop and CI. A suite that loads its own support files in an unpredictable
112
- # order has already lost the argument about determinism.
113
- # -----------------------------------------------------------------------------
114
-
115
- Dir[Rails.root.join("test/support/**/*.rb")].sort.each { |f| require f }
116
-
117
138
  # -----------------------------------------------------------------------------
118
139
  # Code-level configuration.
119
140
  # -----------------------------------------------------------------------------
@@ -57,6 +57,15 @@ fail_on_warnings: false # CI: fail the build when the warning count doesn't tre
57
57
 
58
58
  parallel_workers: auto # or an explicit integer
59
59
 
60
+ # How much the live stream says while the suite is running. The summary is identical
61
+ # either way -- this only changes what you watch on the way there.
62
+ #
63
+ # concise one glyph per test, grouped into a run per case. A thousand tests stay
64
+ # inside one screen, and a wall of green is the point.
65
+ # expanded a line per test: glyph, name, duration. Slower to read in bulk, but you
66
+ # can see which test is hanging while it hangs, rather than after.
67
+ output: concise # concise | expanded
68
+
60
69
  # Fallback path-based tier inference, used only when a case doesn't inherit from
61
70
  # a tiered base class. The base classes in test/case_helper.rb are the primary
62
71
  # mechanism and always win -- this is here so an un-migrated file still lands in
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: constable-rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 1.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ray Hughes
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-09-07 00:00:00.000000000 Z
11
+ date: 2026-09-08 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport
@@ -96,7 +96,7 @@ description: |
96
96
  The gem is published as "constable-rails"; everything inside it -- the module, the
97
97
  CLI, the config directory -- is simply "constable".
98
98
  email:
99
- - r.hughes2136@gmail.com
99
+ - raymond.hughes@live.com
100
100
  executables:
101
101
  - constable
102
102
  extensions: []
@@ -141,6 +141,7 @@ files:
141
141
  - lib/constable/storage/sqlite_adapter.rb
142
142
  - lib/constable/version.rb
143
143
  - lib/constable/warrants.rb
144
+ - lib/constable/worker_databases.rb
144
145
  - lib/generators/constable/base.rb
145
146
  - lib/generators/constable/channel/channel_generator.rb
146
147
  - lib/generators/constable/channel/templates/channel_case.rb.tt